diff --git a/docs/glossary.md b/docs/glossary.md index 2f6071b3f..a3ddcf79f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -892,10 +892,10 @@ NDF を Web UI から操作できるサービスとして提供するときの | スプリントの詳細 | — | 1 つのスプリントの実行の、課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態を出す画面(#2172) | — | — | — | | 詳細パネル | — | スプリントの詳細で工程の行を押すと右側に出る、その工程の中身(触ったファイル・決めたこと・終了コード・秒・費用・作業の報告)の欄(#2172) | — | — | — | | バックログの画面 | — | 取り込んだリポジトリの順位の表と棚卸しの記録を、スプリントの外で読む画面(#2172) | — | — | — | -| フェーズの名前 | — | phase.changed と step.recorded の phase が運ぶ、設計・実装・検査・取り込み・仕上げの 5 つ。factory/contracts/src/phases.ts の列挙が正。ほかの名前も捨てずに残し、画面は「その他(<名前>)」と出す(#2172) | — | — | — | -| 工程の記録 | — | スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172) | — | — | — | -| 工程の中身 | — | 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172) | — | — | — | -| スプリント全体の欄 | — | スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄(#2172) | — | — | — | +| フェーズの名前 | — | phase.changed と step.recorded の phase が運ぶ、設計・実装・検査・取り込み・仕上げの 5 つ。factory/contracts/src/phases.ts の列挙が正。ほかの名前も捨てずに残し、画面は「その他(<名前>)」と出す(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | +| 工程の記録 | — | スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | +| 工程の中身 | — | 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | +| スプリント全体の欄 | — | スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | | 区間 | — | 1 つの実行ジョブの中でランナーが起動する claude -p の 1 回分。conductor の ndf-next で次の区間へ移る | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 区間の上限 | — | 1 つの実行ジョブで起動してよい区間の数の上限(既定 20)。超えたジョブは section_limit で終わる | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 同時のジョブの数 | `max_jobs` | 手元のマシンが同時に動かす実行ジョブの数の上限。手元のマシンが登録と心拍で申告し、サービスはそれを超えて指示を渡さない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index f34e1760d..59f37c2d1 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4735,29 +4735,25 @@ "term": "フェーズの名前", "context": "ndf-factory", "meaning": "phase.changed と step.recorded の phase が運ぶ、設計・実装・検査・取り込み・仕上げの 5 つ。factory/contracts/src/phases.ts の列挙が正。ほかの名前も捨てずに残し、画面は「その他(<名前>)」と出す(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "工程の記録", "context": "ndf-factory", "meaning": "スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "工程の中身", "context": "ndf-factory", "meaning": "1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "スプリント全体の欄", "context": "ndf-factory", "meaning": "スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄(#2172)", - "source": "", - "pending_source": "issues/issue-2172-design.md" + "source": "docs/specifications/ndf-factory-service-boundary.md" }, { "term": "相手役", diff --git a/docs/specifications/README.md b/docs/specifications/README.md index c0dfc2937..7dc9e7fa2 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -99,7 +99,7 @@ | [cross-refactoring-scope-template-probe.md](cross-refactoring-scope-template-probe.md) | cross-refactoring の init が着手前のテストの後に範囲テストのテンプレートを試走し(suite ごとにテストのファイルを最大 2 本)、`{paths}` に入るパスを受けない・確かめられないなら直す箇所を出して改修の前に止まる理由と規則(候補の選び方・判定の表・`template-check.json` の形)。止まり方は `cross-refactoring` の SKILL.md、`{paths}` に入るパスの種類は `development-workflow` の `references/project-analysis.md` が正 | | [cross-review-no-test-declaration-sweep.md](cross-review-no-test-declaration-sweep.md) | テストの無い宣言(`{"suites": []}` を含む)で cross-review の最終スイープが検証のコマンドを名指しせずに進み、駆動の件数に継続的統合の判定 `ci`・`ci_reason` を載せ、supervise の止まった理由に元の終了コードを添える理由と境界(`verify_commands` の判定の順・種別と雛形の誤りは止める)。件数の一覧は `cross-review` の SKILL.md、drive のステップの読み方は `supervise_lib/plan.py` が正 | | [ndf-factory-service-boundary.md](ndf-factory-service-boundary.md) | Neural Development Factory: サービスのディレクトリ(factory/)と境界の検査(check-service-boundary.py)の入出力・常に成り立つ条件と、子課題 #2170〜#2174 が実装するサービス全体の契約(構成要素・配置・進捗の正本・実行ジョブの指示・進捗の事象と受け口の認証と応答)。#2169 | -| [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) | Neural Development Factory: Cognito の認証(Google・GitHub と GitHub の仲立ち)・アカウントの解決・アカウントと席とプランと実行の形態の API と表・使える量の表と判定・factory/ のパッケージの管理と CI(factory-check)。#2170 | +| [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) | Neural Development Factory: Cognito の認証(Google・GitHub と GitHub の仲立ち)・アカウントの解決・アカウントと席と料金プランと実行の形態の API と表・使える量の表と判定・factory/ のパッケージの管理と CI(factory-check)。#2170 | | [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) | Neural Development Factory: 契約の定義(JSON Schema・生成した型・ingest.yaml)・進捗の受け口の 3 つの経路と出所ごとの認証と照合・冪等な追記と事象の表・GitHub の Webhook の翻訳・実行ジョブの token(EdDSA)の発行と検証・appendControlEvent・appendIssueRequestResolved と照合(I37・I44・I45)。#2171・#2216 | | [ndf-factory-progress-data.md](ndf-factory-progress-data.md) | Neural Development Factory: 進捗の記録の事象の表・冪等の鍵の表・環境変数・契約のパッケージ(ndf-factory-contracts-and-progress.md から分けた)。#2171 | | [ndf-factory-issue-requests.md](ndf-factory-issue-requests.md) | Neural Development Factory: 起票の依頼(issue.requested)と起票の代行(判定の順序・下書きの理由・台帳・起票した数の上限・1 リポジトリと Issues の書き込みだけの token)と、ランナーと NDF の依頼の書き出し。#2216 | @@ -123,8 +123,8 @@ | [ndf-relay-multi-session-supervisor.md](ndf-relay-multi-session-supervisor.md) | 同じコンテナで最初のラッパーが統括役を兼ね、2 本目以降の claude・`relay.py spawn`・起動のブロックで名前つきのラッパーを tmux に起動し、`ls` で一覧し、`stop` / `restart` / `attach` で 1 本ずつ扱う入出力の契約・状態の置き場所・決定と理由。手順は `development-workflow` の `references/relay.md` の「複数のループを並べる(統括役)」が正 | | [ndf-backlog-refinement-depths.md](ndf-backlog-refinement-depths.md) | 棚卸しの 3 つの深さ(差分の棚卸し・見直し・大整理)の起動の判定・見直しの持ち主・ニーズとシーズの調査・起票と付け直しと戻しについて、`upkeep.py` の副命令の契約、`.ndf/backlog/` の記録、`materials`・`depths` の設定と、そう決めた理由。手順は `backlog-refinement` の SKILL.md と `references/depths.md`・`review.md`・`research.md`・`overhaul.md` が正 | | [ndf-context-restart-policy.md](ndf-context-restart-policy.md) | 再起動の方針(現行方式と試行方式)の適用値の解決と優先順位、工程境界の帯・容量・費用の判定、判定の記録と「引継ぎ開始」「復元完了」の印、`context-policy.py` の副命令と終了コード、`.ndf/context.json`・状態ディレクトリのファイル・環境変数、作業ごとの集計の区分。手順は `development-workflow` の `references/context-window.md` の「再起動の方針」が正 | -| [ndf-factory-dev-loops.md](ndf-factory-dev-loops.md) | Neural Development Factory: 開発ループ(集約・状態の写し方・周回の開始)・同じリポジトリで並行する実行の取り合い(課題・マイルストーンの持ち分・NDF の版・同時の数)・移行の埋め戻し・ランナーの区間と ndf-next・形態 local の利用上限・手元のランナーの同時のジョブの数・loops.yaml の経路。#2347 | -| [ndf-factory-dev-loops-data-and-contracts.md](ndf-factory-dev-loops-data-and-contracts.md) | Neural Development Factory: 開発ループの実行の表の行・移行の済みの行と埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・ndf-next の受け渡しの形・手元のランナーの申告・loops.yaml の経路の契約(ndf-factory-dev-loops.md から分けた)。#2347 | +| [ndf-factory-dev-loops.md](ndf-factory-dev-loops.md) | Neural Development Factory: 開発ループ(集約・状態の写し方・周回の開始)・同じリポジトリで並行する実行の取り合い(課題・マイルストーンの持ち分・NDF の版・同時の数)・移行の埋め戻し・ランナーの区間と ndf-next・形態 local の利用上限・手元のマシンの同時のジョブの数・loops.yaml の経路。#2347 | +| [ndf-factory-dev-loops-data-and-contracts.md](ndf-factory-dev-loops-data-and-contracts.md) | Neural Development Factory: 開発ループの実行の表の行・移行の済みの行と埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・ndf-next の受け渡しの形・手元のマシンの申告・loops.yaml の経路の契約(ndf-factory-dev-loops.md から分けた)。#2347 | | [ndf-factory-ui-components.md](ndf-factory-ui-components.md) | Neural Development Factory: Web の画面の部品の一覧と使い分け・デザインのトークン・端末の幅・文言と書式(国際化)・API クライアントとダミー・つながりの状態・常に成り立つ条件・検査(check:source・check:bundle・E2E)。#2382 | | [ndf-parallel-organizer.md](ndf-parallel-organizer.md) | 並列の計画(plan.json)を 1 度書き、--parallel のオーガナイザーが耐久ワークフローでレーンを起動・観測し、本番の配布を develop のコミットの範囲で 1 本にまとめ、区分つきの問いを集める設計判断・常に成り立つ条件・メッセージとイベントの形・置き場。手順は development-workflow の references/parallel.md と issue-plan-strategy の references/parallel-plan.md が正 | diff --git a/docs/specifications/ndf-factory-accounts-and-plans.md b/docs/specifications/ndf-factory-accounts-and-plans.md index 1de13af88..9f5f5006f 100644 --- a/docs/specifications/ndf-factory-accounts-and-plans.md +++ b/docs/specifications/ndf-factory-accounts-and-plans.md @@ -1,6 +1,6 @@ -# Neural Development Factory: 認証(Google・GitHub)とアカウント・席・プランごとの使える量 +# Neural Development Factory: 認証(Google・GitHub)とアカウント・席・料金プランごとの使える量 -Neural Development Factory の利用者が Google か GitHub のアカウントで Cognito を経てログインし、要求 1 つから「どの利用者が、どのアカウントとして操作しているか」を決め、プランと席の種類ごとの使える量を 1 か所で問い合わせて判定する仕組みを定める。あわせて、`factory/` の TypeScript のパッケージの管理と検査の形を定める。サービス全体の構成・集約の持ち主・実行の形態の意味は [サービスのディレクトリと境界の検査、サービス全体の契約](ndf-factory-service-boundary.md)(以下「全体の契約」)にあり、本書はそこで集約「アカウント」の持ち主とされた部分(#2170)の確定形である。 +Neural Development Factory の利用者が Google か GitHub のアカウントで Cognito を経てログインし、要求 1 つから「どの利用者が、どのアカウントとして操作しているか」を決め、料金プランと席の種類ごとの使える量を 1 か所で問い合わせて判定する仕組みを定める。あわせて、`factory/` の TypeScript のパッケージの管理と検査の形を定める。サービス全体の構成・集約の持ち主・実行の形態の意味は [サービスのディレクトリと境界の検査、サービス全体の契約](ndf-factory-service-boundary.md)(以下「全体の契約」)にあり、本書はそこで集約「アカウント」の持ち主とされた部分の確定形である。 ## 概要 @@ -12,7 +12,7 @@ Neural Development Factory の利用者が Google か GitHub のアカウント ## 用語 -定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`)。この文書で使う語は「利用者」「アカウント」「席」「プラン」「使える量」「使える量の判定」「チームの持ち主」「実行の形態」「GitHub の仲立ち」である。 +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`)。この文書で使う語は「利用者」「アカウント」「席」「料金プラン」「使える量」「使える量の判定」「チームの持ち主」「実行の形態」「GitHub の仲立ち」である。 ## コンテキスト @@ -33,8 +33,8 @@ Neural Development Factory の利用者が Google か GitHub のアカウント ## 対象範囲 -- **含む**: `factory/web/` のログイン・ログアウトとアカウントの画面、`factory/api/` の token の検証・アカウントの解決・アカウントと席とプランと実行の形態の読み書き・使える量の問い合わせと判定・GitHub の仲立ち、`factory/contracts/openapi/accounts.yaml`、`factory/` のパッケージの管理と CI のジョブ `factory-check` -- **含まない**: Cognito の利用者プール・DynamoDB の表・Lambda の束ね方などインフラの定義(private リポジトリが作る)、決済・料金・請求とプランを有料へ変える経路(#2174)、使用量の台帳とその月の token の集計(#2174)、スプリントの開始を断る処理そのもの(#2186。本書は判定を返す)、実行の形態ごとのランナー・持ち込んだキーの保存(#2186・#2189)、2 つの IdP の利用者をまとめる機能、招待のメール +- **含む**: `factory/web/` のログイン・ログアウトとアカウントの画面、`factory/api/` の token の検証・アカウントの解決・アカウントと席と料金プランと実行の形態の読み書き・使える量の問い合わせと判定・GitHub の仲立ち、`factory/contracts/openapi/accounts.yaml`、`factory/` のパッケージの管理と CI のジョブ `factory-check` +- **含まない**: Cognito の利用者プール・DynamoDB の表・Lambda の束ね方などインフラの定義(private リポジトリが作る)、決済・料金・請求と料金プランを有料へ変える経路、使用量の台帳とその月の token の集計、スプリントの開始を断る処理そのもの(本書は判定を返す)、実行の形態ごとのランナー・持ち込んだキーの保存、2 つの IdP の利用者をまとめる機能、招待のメール - プラグインの配布物・Skill・hook は変わらない。`plugins/` は `factory/` を参照しない(全体の契約の I1) ## 仕様 @@ -43,8 +43,8 @@ Neural Development Factory の利用者が Google か GitHub のアカウント | 集約 | 持ち主 | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | -| 利用者 | 認証とプラン(`factory/api/`) | 利用者 | — | 利用者の ID(Cognito の `sub`)・IdP の種類 | -| アカウント | 認証とプラン(`factory/api/`) | アカウント | 席 | 種類(個人・チーム)・プラン・席の種類・使える量・実行の形態・履歴の行 | +| 利用者 | 認証と料金プラン(`factory/api/`) | 利用者 | — | 利用者の ID(Cognito の `sub`)・IdP の種類 | +| アカウント | 認証と料金プラン(`factory/api/`) | アカウント | 席 | 種類(個人・チーム)・料金プラン・席の種類・使える量・実行の形態・履歴の行 | 初めてのログインは「利用者」と「個人のアカウント」の 2 つを 1 つのトランザクションで作る。2 つは同じ利用者の作成でしか生まれず、片方だけがある状態を作らないためで、「1 つの処理が書き換える集約は 1 つ」(全体の契約の「集約」)の例外はこの作成の時だけである。 @@ -57,8 +57,8 @@ Neural Development Factory の利用者が Google か GitHub のアカウント | アカウントの画面 | `factory/web/src/pages/AccountPage.tsx`・`NewTeamPage.tsx`・`TeamPage.tsx` | 自分のアカウント・チームを作る・チームのアカウントの 3 画面。API の 401 で token を消してログインへ戻す | | token の検証 | `factory/api/src/auth/verify.ts` | `Authorization: Bearer` の access token を検証し、利用者を返す。失敗は 401 | | アカウントの解決 | `factory/api/src/auth/resolve.ts`・`accounts/service.ts` | 経路の前に token を検証する中間処理と、利用者とアカウントの ID から権限つきの文脈を返す処理。利用者が無ければ作る | -| アカウントの操作 | `factory/api/src/accounts/service.ts` | チームの作成・席の追加と種類の変更と削除・プランの変更・実行の形態の変更 | -| 使える量の表と判定 | `factory/api/src/accounts/limits.ts` | プランと席の種類ごとの上限の表と、実行の形態に合わせた問い合わせと判定 | +| アカウントの操作 | `factory/api/src/accounts/service.ts` | チームの作成・席の追加と種類の変更と削除・料金プランの変更・実行の形態の変更 | +| 使える量の表と判定 | `factory/api/src/accounts/limits.ts` | 料金プランと席の種類ごとの上限の表と、実行の形態に合わせた問い合わせと判定 | | アカウントの保存 | `factory/api/src/accounts/store.ts` | DynamoDB の 1 つの表への読み書き。条件つきの書き込みとトランザクション | | API の入口 | `factory/api/src/handlers/api.ts` | API Gateway の HTTP API(payload 2.0)から呼ばれる Lambda の handler。ログ・CORS・認証の中間処理・エラーの形・経路の登録 | | GitHub の仲立ち | `factory/api/src/handlers/github-oidc.ts`・`github-oidc/{app,keys}.ts` | Cognito から OIDC の IdP として呼ばれ、GitHub の OAuth へ取り次ぎ、署名した ID token を返す | @@ -103,7 +103,7 @@ graph TD | `resolveAccount(caller, accountId?)` | `Caller`・アカウントの ID(省くと個人のアカウント) | `AccountContext`(`accountId`・`userId`・`kind`・`plan`・`role`(`owner` / `member`)・`seatType`(チームのときだけ、ほかは `null`)・`executionMode`)。#2186・#2189・#2174 は実行の形態をここから読む | 403 の `ForbiddenError` | | `getLimits(ctx)` | `plan`・`seatType`・`executionMode` | `Limits`(`concurrentSprintRuns`・`monthlyTokens`)。`monthlyTokens` は `managed` のときだけ表の値で、ほかは `null`(掛けない) | 例外を投げない。表に無い組と、`managed` なのに表に月の token の値が無い組は `null` を返す | | `judgeLimits(ctx, current)` | 文脈・`current = {runningSprintRuns, tokensThisMonth?, runningRepoRuns?, repoMaxRuns?}`(後ろの 2 つは同じリポジトリで動いている実行の数と登録の同時の実行の数の宣言。省けば掛けない。#2347) | `Judgment`(`allowed`・`limits`・`reasons`)。`reasons` は `{quantity, limit, current}` の並び | — | -| `setPlan(accountId, plan)` | アカウントの ID・プラン | 無し | `PlanError`(`unknown_plan` / `plan_kind_mismatch` / `account_not_found`)。版の競合で 3 回書けなければ `AccountUnavailableError` | +| `setPlan(accountId, plan)` | アカウントの ID・料金プラン | 無し | `PlanError`(`unknown_plan` / `plan_kind_mismatch` / `account_not_found`)。版の競合で 3 回書けなければ `AccountUnavailableError` | | `createApiApp(deps)`・`requireCaller(deps)`・`resolveFromRequest(c, accountId?)` | — | API の入口の Hono のアプリ・認証の中間処理・経路の中でアカウントの文脈を得る関数 | 上と同じ | あわせて `LIMITS_TABLE`・`AccountService`・エラーの型(`ApiError`・`AuthError`・`ForbiddenError`・`PlanError`)と、`AccountContext`・`Caller`・`ExecutionMode`・`Plan`・`SeatType`・`Limits`・`Judgment`・`CurrentUsage`・`LimitReason`・`AuthEnv`・`ApiApp` の型を export する。 @@ -133,7 +133,7 @@ graph TD CTX --> H ``` -**初回の作成は「利用者の行が無いこと」を条件にしたトランザクションで書く。** 利用者・個人のアカウント(プラン `free`・形態 `local`・版 1)・`account.created` の履歴の行を一緒に書く。並べて 2 回来ても条件で片方だけが通り、失敗した側は利用者を読み直して先にできた方を返す。読み直しても無いか、条件以外で失敗したときは 503 `account_unavailable` で、次の要求で作り直す。 +**初回の作成は「利用者の行が無いこと」を条件にしたトランザクションで書く。** 利用者・個人のアカウント(料金プラン `free`・形態 `local`・版 1)・`account.created` の履歴の行を一緒に書く。並べて 2 回来ても条件で片方だけが通り、失敗した側は利用者を読み直して先にできた方を返す。読み直しても無いか、条件以外で失敗したときは 503 `account_unavailable` で、次の要求で作り直す。 ### ログインの流れ @@ -181,24 +181,24 @@ sequenceDiagram | # | 集約 | 条件 | 破れそうなときの扱い | | --- | --- | --- | --- | | I12 | 利用者 | 1 人の利用者の個人のアカウントは 1 つだけ | 2 つ目の作成は条件つきの書き込みで失敗し、先にできた方を読み直して返す | -| I13 | アカウント | 個人のアカウントは席を持たず、プランは `free`・`pro`・`max` のどれか。チームのアカウントのプランは `team` | 個人のアカウントへの席の操作は 409 `not_a_team`。合わない組の `setPlan` は `plan_kind_mismatch` で変えない | +| I13 | アカウント | 個人のアカウントは席を持たず、料金プランは `free`・`pro`・`max` のどれか。チームのアカウントの料金プランは `team` | 個人のアカウントへの席の操作は 409 `not_a_team`。合わない組の `setPlan` は `plan_kind_mismatch` で変えない | | I14 | アカウント | チームのアカウントでは、持ち主が必ず席を 1 つ持つ | 持ち主の席を外す要求は 409 `owner_seat_protected` | | I15 | アカウント | 1 人の利用者は 1 つのチームに席を 1 つまで持つ | 2 つ目の追加は 409 `seat_exists` | | I16 | アカウント | 席が指す利用者は、既に 1 度ログインした利用者である | 422 `user_not_found` | | I17 | アカウント | 席を足す・種類を変える・外すのは、チームの持ち主だけ | 403 `forbidden`。席の有無を確かめる前に返す | | I18 | アカウント | 個人のアカウントの持ち主でもチームの席を持つ利用者でもない者には、アカウントの有無も中身も応答に出ない | 存在しないアカウントと同じ 403 と同じ本文 | | I19 | アカウント | 使える量の値は使える量の表 1 か所からだけ決まる | 表に無い組は判定を断る側へ倒し、理由に `not_in_limits_table` を返す | -| I20 | アカウント | プラン・席・実行の形態を変えるたびに、同じトランザクションで履歴の行を 1 つ足し、履歴の行は書き換えない | 履歴を書けなければ変更ごと書かない | -| I21 | アカウント | プランが `free` のアカウントの実行の形態は `managed` でない | 形態を `managed` へ変える要求は 409 `managed_not_allowed_on_free`。`setPlan` で `free` へ下げるとき形態が `managed` なら、同じ更新で `local` へ変え、`plan.changed` と `execution_mode.changed` の 2 つの履歴の行を足す | +| I20 | アカウント | 料金プラン・席・実行の形態を変えるたびに、同じトランザクションで履歴の行を 1 つ足し、履歴の行は書き換えない | 履歴を書けなければ変更ごと書かない | +| I21 | アカウント | 料金プランが `free` のアカウントの実行の形態は `managed` でない | 形態を `managed` へ変える要求は 409 `managed_not_allowed_on_free`。`setPlan` で `free` へ下げるとき形態が `managed` なら、同じ更新で `local` へ変え、`plan.changed` と `execution_mode.changed` の 2 つの履歴の行を足す | | I22 | アカウント | 実行の形態を変えるのは、アカウントの持ち主(個人は本人、チームはチームの持ち主)だけ | 403 `forbidden` | | I23 | アカウント | 月の token の上限を照らすのは、実行の形態が `managed` のときだけ。同時に動かせるスプリントの実行の数は 3 つの形態すべてで照らす | `local`・`byok` の判定は月の token の今の値を見ない | | I24 | アカウント | 新しいアカウント(個人もチームも)の実行の形態は `local` で始まる | 作成はいつも `local` で書く | -**プランと形態の更新は、読んだアカウントの行の版が変わっていないことを条件に書く。** アカウントの行は数の `version` を持ち、`setPlan` と形態の変更はどちらも「行があり、`version` が読んだ値と等しいこと」を条件にして、同じ更新で `version` を 1 増やす。条件で失敗したら読み直して I13・I21 を判定し直し、もう一度書く(3 回まで)。これが無いと、プロ・`local` を読んだ `setPlan` が形態を変えないと決めた後に別の要求が `managed` を保存し、続けて `setPlan` が `free` を保存して、`free`・`managed` が残る。席の種類の変更と削除も同じく、読んだ席の種類と足した時刻が変わっていないことを条件にし、競合したら読み直す(3 回まで)。外して足し直した新しい席を古い要求で消さないためである。3 回で書けなければ 503 `account_unavailable` を返す。 +**料金プランと形態の更新は、読んだアカウントの行の版が変わっていないことを条件に書く。** アカウントの行は数の `version` を持ち、`setPlan` と形態の変更はどちらも「行があり、`version` が読んだ値と等しいこと」を条件にして、同じ更新で `version` を 1 増やす。条件で失敗したら読み直して I13・I21 を判定し直し、もう一度書く(3 回まで)。これが無いと、プロ・`local` を読んだ `setPlan` が形態を変えないと決めた後に別の要求が `managed` を保存し、続けて `setPlan` が `free` を保存して、`free`・`managed` が残る。席の種類の変更と削除も同じく、読んだ席の種類と足した時刻が変わっていないことを条件にし、競合したら読み直す(3 回まで)。外して足し直した新しい席を古い要求で消さないためである。3 回で書けなければ 503 `account_unavailable` を返す。 ### 使える量の表と判定 -| プラン・席の種類(表の鍵) | 同時に動かせるスプリントの実行 | 月の token の上限(`managed` だけ) | +| 料金プラン・席の種類(表の鍵) | 同時に動かせるスプリントの実行 | 月の token の上限(`managed` だけ) | | --- | --- | --- | | Free(`free`) | 1 | 掛けない(`managed` を選べない) | | 個人 Pro(`pro`) | 3 | 20,000,000 | @@ -206,11 +206,11 @@ sequenceDiagram | Team standard(`team:standard`) | 3 | 25,000,000 | | Team premium(`team:premium`) | 10 | 125,000,000 | -- **鍵は個人のアカウントではプラン、チームでは `team:<席の種類>` である。** チームの使える量は、要求した利用者の席の種類で決まる +- **鍵は個人のアカウントでは料金プラン、チームでは `team:<席の種類>` である。** チームの使える量は、要求した利用者の席の種類で決まる - **月の token は入力と出力の和で、cache の読み書きを含めて数える。** Bedrock の請求と同じ単位で数えるためである。cache を除くと、NDF の実行ではほぼ全部が出力になり、同じ上限でも費用が大きく変わる - **値の比は依頼の「Claude.aiと類似の」に合わせた。** Pro と Max の比を保ち、Team standard を個人 Pro の少し上、Team premium を個人 Max の少し上に置く - **判定は「今の値が上限より小さければ許す」である。** 同時のスプリントの実行は新しい 1 つを始める前の数を渡し、上限 3 なら今が 2 で許し、3 で断る。月の token はその UTC の暦月に使った数(チームではその席の利用者の分)を渡し、上限ちょうどで断る。`managed` で `tokensThisMonth` を省くと断る -- 断ったときの `reasons` の `quantity` は `concurrent_sprint_runs` / `concurrent_repo_runs` / `monthly_tokens` / `not_in_limits_table` で(`concurrent_repo_runs` の上限はプランの同時の数と登録の宣言の小さい方で、宣言が無くアカウントの上限と同時に当たるときは重ねない)、`limit` と `current` に上限と今の値を入れる。画面と #2186 は同じ値から利用者に出す文を作る +- 断ったときの `reasons` の `quantity` は `concurrent_sprint_runs` / `concurrent_repo_runs` / `monthly_tokens` / `not_in_limits_table` で(`concurrent_repo_runs` の上限は料金プランの同時の数と登録の宣言の小さい方で、宣言が無くアカウントの上限と同時に当たるときは重ねない)、`limit` と `current` に上限と今の値を入れる。画面と #2186 は同じ値から利用者に出す文を作る ### API の経路 @@ -221,14 +221,14 @@ sequenceDiagram | `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` | +| `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 | -- **持ち主の確かめは、席の有無とプランの照合より先に行う。** 404 `seat_not_found` と 409 `managed_not_allowed_on_free` は、要求した者が持ち主と確かめた後だけ返す +- **持ち主の確かめは、席の有無と料金プランの照合より先に行う。** 404 `seat_not_found` と 409 `managed_not_allowed_on_free` は、要求した者が持ち主と確かめた後だけ返す - **`byok` を選んでもキーの登録はこの経路で求めない。** キーの保存は #2186 が持つ -- **`GET /me` のチームの一覧は GSI を候補として引き、元の席を強い整合で確かめる。** 外した席が GSI に残る間に、そのチームの名前やプランを返さないためである +- **`GET /me` のチームの一覧は GSI を候補として引き、元の席を強い整合で確かめる。** 外した席が GSI に残る間に、そのチームの名前や料金プランを返さないためである - 本文が JSON のオブジェクトでなければ 400 `invalid_request`。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える ### GitHub の仲立ち @@ -255,9 +255,9 @@ sequenceDiagram | --- | --- | --- | | ログイン | `/login` | Google と GitHub の 2 つのボタン。`?error=` があれば「ログインできませんでした」を出す | | ログインの戻り | `/callback` | code を token に換え、`/account` へ移る。`error` つきの戻りは token を保存せず `/login?error=…` へ移る | -| 自分のアカウント | `/account` | 利用者の ID(写すボタン)・IdP・個人のプランと実行の形態と 2 つの上限(`managed` でなければ月の token は「掛けない」)・形態を変える操作・席を持つチームの一覧・チームを作る・ログアウト | +| 自分のアカウント | `/account` | 利用者の ID(写すボタン)・IdP・個人の料金プランと実行の形態と 2 つの上限(`managed` でなければ月の token は「掛けない」)・形態を変える操作・席を持つチームの一覧・チームを作る・ログアウト | | チームを作る | `/teams/new` | 名前(1〜64 字)と自分の席の種類(既定は standard)を入れて作る | -| チームのアカウント | `/accounts/:accountId` | プラン・実行の形態・自分の席の種類と 2 つの上限・席の一覧。持ち主には席の追加(利用者の ID は UUID の形で 36 字まで)・種類の変更・削除と形態を変える操作が出る | +| チームのアカウント | `/accounts/:accountId` | 料金プラン・実行の形態・自分の席の種類と 2 つの上限・席の一覧。持ち主には席の追加(利用者の ID は UUID の形で 36 字まで)・種類の変更・削除と形態を変える操作が出る | それ以外の経路は `/account` へ移る。Free では形態の選択肢の `managed` を選べない。 @@ -305,7 +305,7 @@ GSI `gsi1` は利用者が席を持つチームの一覧を引くためにあり | アカウント | `kind` | 文字列 | 許さない | `personal` / `team` | | アカウント | `plan` | 文字列 | 許さない | `free` / `pro` / `max` / `team`(I13) | | アカウント | `execution_mode` | 文字列 | 許さない | `local` / `byok` / `managed`(I21・I24) | -| アカウント | `version` | 数 | 許さない | 作成のときは 1。プランと形態を変えるたびに 1 増やす | +| アカウント | `version` | 数 | 許さない | 作成のときは 1。料金プランと形態を変えるたびに 1 増やす | | アカウント | `owner_user_id` | 文字列 | 許さない | 個人は本人、チームはチームの持ち主 | | アカウント | `name` | 文字列 | 許す | チームの名前。空は「個人のアカウント」 | | アカウント | `created_at`・`updated_at` | 文字列(ISO 8601、UTC) | 許さない | 作成と最後の変更の時刻 | @@ -326,7 +326,7 @@ GSI `gsi1` は利用者が席を持つチームの一覧を引くためにあり | 席を操作する | R(相手) | R | C・U・D | C | | 解決する | C(初回)・R | C(初回)・R | R | C(初回) | | 使える量 | — | R | R | — | -| プランを変える(`setPlan`) | — | U(`free` へ下げるときは形態も) | — | C | +| 料金プランを変える(`setPlan`) | — | U(`free` へ下げるときは形態も) | — | C | | 実行の形態を変える | — | R・U | R | C | ### 環境変数 @@ -338,12 +338,13 @@ GSI `gsi1` は利用者が席を持つチームの一覧を引くためにあり | `COGNITO_DOMAIN` | 画面・GitHub の仲立ち | Cognito の管理されたログインのドメイン。仲立ちは `/authorize` で受ける `redirect_uri` をこのドメインの `/oauth2/idpresponse` と照らす | | `FACTORY_WEB_URL` | 画面・API | 画面の URL。画面はログインとログアウトの戻りに、API は CORS で許すオリジンに使う | | `FACTORY_API_URL` | 画面 | API の入口の URL | +| `FACTORY_POLL_INTERVAL_MS` | 画面 | スプリントの一覧と詳細を読み直す間隔(ミリ秒)。正の整数でなければ既定の 30,000 | | `ACCOUNTS_TABLE_NAME` | API | アカウントの表の名前 | | `GITHUB_OIDC_ISSUER` | GitHub の仲立ち | 仲立ちの発行元の URL | | `GITHUB_OAUTH_CLIENT_ID` | GitHub の仲立ち | GitHub の OAuth App のクライアントの ID(秘密でない)。`/authorize` で受ける `client_id` と照らす | | `GITHUB_OIDC_SIGNING_KEY_SECRET_ARN` | GitHub の仲立ち | ID token を署名する RSA の秘密鍵の置き場 | -画面へは `vite.config.ts` が `COGNITO_DOMAIN`・`COGNITO_CLIENT_ID`・`FACTORY_WEB_URL`・`FACTORY_API_URL` の 4 つだけを名前で選んで埋め込む。ほかの変数を画面へ入れない。API と仲立ちは、要る変数が無ければ最初の要求の処理で失敗する。 +画面へは `vite.config.ts` が `COGNITO_DOMAIN`・`COGNITO_CLIENT_ID`・`FACTORY_WEB_URL`・`FACTORY_API_URL`・`FACTORY_POLL_INTERVAL_MS` の 5 つだけを名前で選んで埋め込む。ほかの変数を画面へ入れない。API と仲立ちは、要る変数が無ければ最初の要求の処理で失敗する。 ### パッケージの管理 @@ -417,7 +418,7 @@ graph TD | 観点 | 縛る振る舞い | | --- | --- | -| ログイン | 2 つのボタンがそれぞれ `identity_provider=Google` / `GitHub` を付けて Cognito へ送り、戻りの後に `/account` がプランを出すこと | +| ログイン | 2 つのボタンがそれぞれ `identity_provider=Google` / `GitHub` を付けて Cognito へ送り、戻りの後に `/account` が料金プランを出すこと | | ログインの失敗 | `error` つきの戻りで `/login` に「ログインできませんでした」が出て、保存先に token が無いこと | | ログアウト | 保存先の token が消え、`/account` を開くと `/login` へ移ること。API の 401 でも同じであること | | token の検証 | token が無い・署名が違う・期限切れ・別の利用者プール・別のクライアント・ID token のそれぞれで 401 になり、本文にアカウントの中身が無いこと | @@ -431,17 +432,17 @@ graph TD | 席の条件(I14・I15・I16) | 未ログインの利用者 → 422、既に席がある → 409、持ち主の席を外す → 409 で、どれも席の一覧と履歴が変わらないこと | | 個人のアカウントは 1 つ(I12) | 同じ利用者で `GET /me` を並べて 2 回呼んでも個人のアカウントは 1 つで、2 回とも同じ ID を返すこと | | 他人のアカウント(I18) | 利用者 A の token で、他人の個人のアカウント・入っていないチーム・存在しない ID の 3 つに読む・書くを送ると、すべて 403 で本文が同じであること | -| プランの組(I13) | 個人のアカウントへの席の操作は 409、`setPlan` で個人に `team`・チームに `pro` は失敗で、行が変わらないこと | -| 履歴(I20) | 席・プラン・実行の形態の変更のたびに履歴の行が 1 つ増え、変更が失敗したときは増えないこと | +| 料金プランの組(I13) | 個人のアカウントへの席の操作は 409、`setPlan` で個人に `team`・チームに `pro` は失敗で、行が変わらないこと | +| 履歴(I20) | 席・料金プラン・実行の形態の変更のたびに履歴の行が 1 つ増え、変更が失敗したときは増えないこと | | 実行の形態の読み出し | `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` であること | +| Free へ下げる(I21) | `managed` のプロのアカウントへ `setPlan(…, "free")` を呼ぶと、料金プランが `free`・形態が `local` になり、2 つの履歴の行が同じトランザクションで増えること。形態の書き込みが失敗すれば料金プランも変わらないこと | +| 版の競合(I21) | `setPlan` の書き込みの前に別の要求が `managed` を保存して版を進めても、`setPlan` が読み直して、終わったとき料金プランが `free`・形態が `local` であること | | GitHub の仲立ち | ID token の `sub` が GitHub の数の利用者 ID、`aud` が `client_id` で、公開した鍵で検証できること。検証済みでないメールを返さないこと。`/authorize` の `nonce` が ID token に同じ値で出ること。署名の壊れた・期限の切れた・`client_id` か `redirect_uri` の違う `state` と `code` を断ること。`/authorize` に違う `redirect_uri` か `client_id` が来たら GitHub へ送らずに 400 を返すこと | | 秘密を出さない | ログの出力に token・`client_secret`・鍵の形が無いこと。`python3 scripts/check-service-boundary.py` が 0 で終わること | | パッケージの管理と CI | `npm ci` がロックファイルどおりに入り、`npm run check -w web`・`npm run check -w api` が手元と CI の両方で走り、型の誤りかテストの失敗で 1 になること。`factory/` を変えない Pull Request ではジョブが成功で終わること | | 既存への影響 | `uv run --frozen --project . --all-extras pytest . -q -n 4`・`claude plugin validate .`・既存の必須のチェックが変わらず通り、プラグインの配布物と CLI だけで使う人の振る舞いが変わらないこと | -| 実環境のログイン | private リポジトリのインフラが利用者プールを作った後、実際の Google と GitHub のアカウントでログインし、プラン Free・形態 `local` の個人のアカウントができること | +| 実環境のログイン | private リポジトリのインフラが利用者プールを作った後、実際の Google と GitHub のアカウントでログインし、料金プラン Free・形態 `local` の個人のアカウントができること | ## 関連リンク diff --git a/docs/specifications/ndf-factory-contracts-and-progress.md b/docs/specifications/ndf-factory-contracts-and-progress.md index c891b6116..7f31250ad 100644 --- a/docs/specifications/ndf-factory-contracts-and-progress.md +++ b/docs/specifications/ndf-factory-contracts-and-progress.md @@ -147,8 +147,8 @@ graph TD | 種類 | 鍵(型。任意のものは「任意」) | | --- | --- | | `job.started` | `ndf_version`(文字列)、`start`(`new` / `resume` / `loop`) | -| `phase.changed` | `phase`(文字列。フェーズの名前の集合は #2172 が決めるため列挙にしない)、`issues`(1 以上の整数の並び) | -| `step.recorded` | `step`(文字列)、`exit_code`(整数)、`seconds`(0 以上の数)、`summary`(文字列)、`pull_request`(任意。`repo`(`owner/name`)・`number`(1 以上の整数)) | +| `phase.changed` | `phase`(1 字以上の文字列。5 つの名前は `src/phases.ts` の列挙が正で、定義では列挙で縛らない)、`issues`(1 以上の整数の並び) | +| `step.recorded` | `step`(文字列)、`exit_code`(整数)、`seconds`(0 以上の数)、`summary`(文字列)、`pull_request`(任意。`repo`(`owner/name`)・`number`(1 以上の整数))。工程の中身はどれも任意: `phase`(1 字以上の文字列)、`issues`(1 以上の整数の並び、最大 50)、`files`(`path`(1〜512 字)・`added`・`deleted`(0 以上の整数か、バイナリなら `null`)の並び、最大 100)、`files_omitted`(0 以上の整数)、`commit_range`(`from`・`to`。どちらも 40 桁の 16 進)、`decisions`(`question`・`answer`(1〜1,000 字)・`by`(`ai` / `user`)の並び、最大 20)、`output`(`kind`(`report` / `log`)・`text`(32,768 字以下)・`truncated`(真偽)) | | `attention` | `reason`(文字列)、`summary`(文字列) | | `gate.reached` | `kind`(`gate1` / `gate2` / `decision`)、`pr_url`(URI か `null`)、`material`(文字列。承認資料の本文)、`loop`(任意。開発ループの実行のとき `name`(ループの名前)・`lap`(1 以上の整数)) | | `gate.answered` | `approval_request_id`(文字列)、`kind`(`gate1` / `gate2` / `decision`)、`decision`(`approved` / `rejected`。`kind` が `decision` のときは持たず、ほかでは必須)、`answered_by_account_id`(文字列。#2170 の解決の結果の `accountId`)。本文は持たない | @@ -163,6 +163,34 @@ graph TD **大きさの上限は 256 KiB(262,144 バイト、`MAX_EVENT_BYTES`)で、定義の外で見る。** `runner`・`api` の経路は要求の本文の UTF-8 のバイト数、`github` の経路と `appendControlEvent` は作った事象を JSON にしたバイト数で見る。JSON Schema の `maxLength` は字の数でバイトを数えられないためである。 +### スプリントの一覧と詳細の読み出し + +一覧・詳細・工程の中身・バックログは、進捗の事象の列とスプリントの実行の行から読み出しのたびに導く派生で、表に保存しない。派生は課題の本文・ボード・GitHub の API を読まない(バックログの順位の表だけは GitHub の口の `findRankingTable` で読む)。派生の関数は `factory/api/src/views/derive.ts`、経路は `factory/api/src/routes/sprint-views.ts`、形は `factory/contracts/openapi/runs.yaml` にある。 + +**フェーズの名前は `設計`・`実装`・`検査`・`取り込み`・`仕上げ` の 5 つで、`factory/contracts/src/phases.ts` の列挙が正である。** 定義(`progress-event.schema.json`)では `phase` を文字列のまま受け、列挙で縛らない。NDF の版がフェーズを足しても事象を 400 にせず残すためで、画面は 5 つに無い名前を「その他(<名前>)」と出す。画面も同じ列挙を `@ndf-factory/contracts/phases`(依存を持たない入口)から読む。ランナーの翻訳は NDF のプランの JSON の `"フェーズ"` の値を次のとおり写す。プランの JSON が読めない・表に無い値は計画の名前の断片(`5-impl-2172` なら `impl`)を送る。 + +| プランの `"フェーズ"` | 写すフェーズの名前 | +| --- | --- | +| `設計`・`実装`・`検査` | 同じ名前 | +| `配布(開発版)` | `取り込み` | +| `配布(本番)`・`まとめ` | `仕上げ` | + +**ランナーは `step.recorded` に工程の中身を任意の項目として添える(`schema_version` は 1 のまま)。** 項目は `phase`(プランの `"フェーズ"` を写した名前)・`issues`(計画の名前の課題の番号。スプリントで 1 つの計画は持たない)・`files`(その工程の時間の窓 `at − seconds` 〜 `at` に作業場所の git で作られたコミットが触ったファイルと増減の行数。最大 100 件で、外した数を `files_omitted`)・`commit_range`(窓の最初のコミットの親と最後のコミット)・`decisions`(判断の台帳の `decision` の行のうち、`cwd` が作業場所の下で `at` が窓の中のもの。最大 20 件)・`output`(ステップの出力。`work` のステップは作業の報告、`run` のステップはログ。UTF-8 で 32 KiB まで)である。集められない項目は省き、工程の送信は止めない。`output.text`・`decisions` の文・`files` のパスは、送る前に NDF の `secret_redact.py` の 4 つの形の規則と、ランナーが持つ秘密の値(実行ジョブの token・導入の token・持ち込んだキー・引き受けたロールの資格)の置き換えで伏せる。事象 1 件が 256 KiB を超えるときは `output.text` → `files` → `decisions` の順に削り、それでも超えれば工程の中身をすべて外す。同じステップの行から作る `step.recorded` と `usage.recorded` は同じ `at` で送る。 + +| 派生の値 | 導き方 | +| --- | --- | +| 一覧の今のフェーズ | その実行の最後の `phase.changed`(`order_key` の順)の `phase`。0 件なら `null`(画面は「始まる前」) | +| 工程のフェーズ | `step.recorded` の `phase`。無ければ同じ実行ジョブの直前の `phase.changed` の `phase` | +| 課題の欄 | `step.recorded` の `issues` の課題ごとに 1 回ずつ。`issues` が無いか空なら「スプリント全体」の欄 | +| 課題の欄の Pull Request | 欄の工程の `pull_request` を重複なく。状態は最後の `pr.updated`(無ければ `unknown`)、チェックは名前ごとの最後の `ci.updated` の結論(`null` は実行中) | +| 工程の費用 | 同じ `job_id`・`payload.step`・`at` の `usage.recorded` の `cost_usd` の和。無ければ `null` | +| 承認の欄 | ゲート 1・ゲート 2・判断の 3 つ。その種類の最後の `gate.reached` で待ちになり、その後の同じ種類の `gate.answered` で承認・差し戻し(判断は答えた)になる。`gate.reached` の前の答えは状態を変えない | +| 仕上げの欄 | 振り返り(`step` が `retro`)と棚卸し(`refine`)の行を常に持ち、記録が無ければ「まだ」 | +| 詳細の件数の上限 | `step.recorded` は新しい方から 2,000 件までで、省いた数を `omitted_steps` に返す。ほかの種類はすべて読み、承認の欄と使用量の和から欠かさない | +| バックログの棚卸しの記録 | そのリポジトリの実行(新しい順に 50 件まで)の、`step` が `refine` の `step.recorded` を新しい順に | + +詳細の応答は工程の中身の本文を持たず(`has_details` で有無だけを示す)、画面は工程を押したときに工程の中身の経路で 1 件ずつ読む。一覧・詳細・工程の中身・バックログは、他人のアカウント・無いアカウント・アカウントに属さない実行・無い実行・無い工程・登録の無いリポジトリのどれでも、同じ本文の 403 を返す。読み出しは事象の表・実行の表・GitHub へ書かない。画面は一覧と詳細を開いている間、画面の設定の間隔(環境変数 `FACTORY_POLL_INTERVAL_MS`、既定 30 秒)で読み直し、押し出しの通知は使わない。 + ### 実行ジョブの指示の定義 `factory/contracts/schemas/run-job-instruction.schema.json`。全体の契約の「実行ジョブの指示」の 11 の入力と、#2347 が足した開発ループの 5 つの入力(下の表の後ろの 5 行)を、環境変数の名前のまま `properties` に置く。値はどれも文字列で(環境変数と、#2189 の受け取りの応答で同じ形にするため)、`additionalProperties: false`。生成した型は `RunJobInstruction`。 @@ -443,7 +471,7 @@ graph TD - 課題: [#2171](https://github.com/devbasex/ai-plugins/issues/2171)(要求と受け入れ条件の正本)、親の [#2168](https://github.com/devbasex/ai-plugins/issues/2168)、照合と起票の依頼の [#2216](https://github.com/devbasex/ai-plugins/issues/2216)(設計 Pull Request [#2233](https://github.com/devbasex/ai-plugins/pull/2233))、相手先の流儀の判定の [#2239](https://github.com/devbasex/ai-plugins/issues/2239) - 設計 Pull Request: [#2201](https://github.com/devbasex/ai-plugins/pull/2201)、実装: [#2207](https://github.com/devbasex/ai-plugins/pull/2207)・[#2209](https://github.com/devbasex/ai-plugins/pull/2209) - サービス全体の契約: [ndf-factory-service-boundary.md](ndf-factory-service-boundary.md) -- 認証とアカウント・席・プランの使える量(#2170): [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) +- 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) - サービスのディレクトリとパッケージの管理: [factory/README.md](../../factory/README.md) - 受け口の契約: [factory/contracts/openapi/ingest.yaml](../../factory/contracts/openapi/ingest.yaml)、定義: [factory/contracts/schemas/](../../factory/contracts/schemas/) - 用語: [用語集](../glossary.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 de53d6c3d..41e3f8e0b 100644 --- a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md +++ b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md @@ -1,6 +1,6 @@ # Neural Development Factory: 開発ループのデータと契約 -[開発ループと並行する実行の調整](ndf-factory-dev-loops.md) の実行の表の行・移行の埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のランナーの申告・画面向けの経路の契約を定める(ndf-factory-dev-loops.md から分けた)。機械可読な正は `factory/contracts/` にある。 +[開発ループと並行する実行の調整](ndf-factory-dev-loops.md) の実行の表の行・移行の埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のマシンの申告・画面向けの経路の契約を定める(ndf-factory-dev-loops.md から分けた)。機械可読な正は `factory/contracts/` にある。 ## データ・設定 @@ -14,7 +14,7 @@ erDiagram "開発ループ" ||--o{ "スプリントの実行" : "周回ごとに起こす" "開発ループ" ||--|{ "マイルストーンの持ち分" : "占める" "スプリントの実行" ||--o{ "課題の持ち分" : "占める" - "手元のランナーの枠" ||--o{ "実行ジョブ" : "数える" + "手元のマシンの枠" ||--o{ "実行ジョブ" : "数える" ``` | 行 | `pk` | `sk` | `gsi1pk` / `gsi1sk` | @@ -23,7 +23,7 @@ erDiagram | ループの実行(周回の開始の書き込みで足す。列は `sprintRunId`) | `ACCOUNT#` | `LOOPRUN##` | — | | マイルストーンの持ち分 | `ACCOUNT#` | `MILESTONE##<題の sha256 の先頭 32 字>` | — | | 課題の持ち分 | `ACCOUNT#` | `ISSUE##<番号>` | — | -| 手元のランナーの枠 | `ACCOUNT#` | `RUNNER#` | — | +| 手元のマシンの枠 | `ACCOUNT#` | `RUNNER#` | — | | 移行の済み(表に 1 つ) | `MIGRATION` | `MIGRATED#2347` | — | | 行 | 列 | 型 | 空 | 意味 | @@ -42,18 +42,18 @@ erDiagram | ループ | `answers` | `{number, decision, pr}` の並び | 不可 | 画面の答え。次のジョブの `LOOP_ANSWERS` に載り、`pending` から消えた番号は外す | | ループ | `cancelRequested`・`createdBy`・`createdAt`・`updatedAt`・`lastRunAt` | — | `lastRunAt` だけ可 | 取り消しの要求と時刻(`lastRunAt` は最後の実行の `updatedAt`) | | 持ち分 | `loopId`・`title` / `sprintRunId` | 文字列 | 不可 | 占めるループと題 / 占める実行 | -| ランナーの枠 | `count` | 数 | 不可 | `running` の `local` のジョブの数(I50) | +| 手元のマシンの枠 | `count` | 数 | 不可 | `running` の `local` のジョブの数(I50) | | 実行 | `loopId`・`lap` | 文字列・数 | 可 | 空は手動の実行 | | 実行 | `legacy` | 真偽 | 可 | 古い版として `legacyCount` に数えた実行。作成と移行の埋め戻しで書き、終わりへの移りで真のときだけ `legacyCount` を減らす。空は「埋め戻していない既存の実行」 | | 実行 | `finishReason` | 文字列 | 可 | `job.finished` の `reason`(`section_limit` / `usage_limit`) | | 実行 | `resumeAt` | 文字列 | 可 | `awaiting_reset` の間だけ。リセット時刻(読めなければ `USAGE_LIMIT_RETRY_SECONDS` 後) | -| ジョブ | `runnerId` | 文字列 | 可 | `local` で受け取ったランナー。手元のランナーの枠を減らす先 | +| ジョブ | `runnerId` | 文字列 | 可 | `local` で受け取った手元のマシン。手元のマシンの枠を減らす先 | | リポジトリの枠 | `legacyCount` | 数 | 不可 | 並行を許す版の下限より古い版の、終わりの状態でない実行の数(I48)。無いのは 0 | -| 登録 | `maxConcurrentRuns` | 数 | 可 | 空は「宣言しない(プランの値)」 | +| 登録 | `maxConcurrentRuns` | 数 | 可 | 空は「宣言しない(料金プランの値)」 | 実行の状態には `awaiting_reset` がある(終わりの状態でない)。`maxConcurrentRuns`・`loopId` などの無い行は空として読む。 -| 機能 | ループ | 実行 | ジョブ | 枠・持ち分・ランナーの枠 | 登録 | 進捗の記録 | +| 機能 | ループ | 実行 | ジョブ | 枠・持ち分・手元のマシンの枠 | 登録 | 進捗の記録 | | --- | --- | --- | --- | --- | --- | --- | | 始める | C | C | C | C・U | R | — | | 止める | U | U | — | U・D | — | R | @@ -138,7 +138,7 @@ erDiagram - 規則の持ち主は NDF で、NDF の変更が例を直す。ランナーは実行時に NDF をマーケットプレイスから入れ、`plugins/` を参照しないため、規則を写す。写した規則が古くなるのを、NDF の pytest(`next_blocks`・`_mark_action`)と Factory の vitest(`next-block.ts`)が同じ例の組を読むことで防ぐ(I55) - `scripts/contracts_lib/model.py` は `forms/*.json` を読み、上の鍵の型を確かめる(知らない鍵は断る)。Factory はテストだけが `../plugins/ndf/contracts/forms/ndf-next-block.json` を読む。区分 `forms/` は [NDF の入口の契約](ndf-entrypoint-contracts.md) の一覧にある -### 手元のランナーの申告(`local-runner-declaration.schema.json`) +### 手元のマシンの申告(`local-runner-declaration.schema.json`) | 鍵 | 型・必須 | 意味 | | --- | --- | --- | @@ -146,7 +146,7 @@ erDiagram | `max_jobs` | 1〜16 の整数・任意(無ければ 1) | 同時のジョブの数 | | `capacity_hint` | 1 以上の整数・任意 | 目安。表示だけに使う | -手元のランナーの登録と心拍の本文はこの形を含む。 +手元のマシンの登録と心拍の本文はこの形を含む。 ### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`) diff --git a/docs/specifications/ndf-factory-dev-loops.md b/docs/specifications/ndf-factory-dev-loops.md index 3dc2bb1c3..aee611953 100644 --- a/docs/specifications/ndf-factory-dev-loops.md +++ b/docs/specifications/ndf-factory-dev-loops.md @@ -1,6 +1,6 @@ -# Neural Development Factory: 開発ループと並行する実行の調整・ジョブの中の区間・利用上限・手元のランナーの同時のジョブの数 +# Neural Development Factory: 開発ループと並行する実行の調整・ジョブの中の区間・利用上限・手元のマシンの同時のジョブの数 -Neural Development Factory の利用者は、リポジトリごとに方針の違う開発ループを Web から始め・止め・予算を変え、一覧と詳細で周回と止まった理由を見る。同じリポジトリで並行する実行どうしの取り合い(課題・マイルストーン・NDF の版・同時の数)と、手元のランナーの同時のジョブの数は機械が守る。ランナーは 1 つの実行ジョブの中で `ndf-next` の区間をつなぎ、手元の relay.py と同じく続ける。 +Neural Development Factory の利用者は、リポジトリごとに方針の違う開発ループを Web から始め・止め・予算を変え、一覧と詳細で周回と止まった理由を見る。同じリポジトリで並行する実行どうしの取り合い(課題・マイルストーン・NDF の版・同時の数)と、手元のマシンの同時のジョブの数は機械が守る。ランナーは 1 つの実行ジョブの中で `ndf-next` の区間をつなぎ、手元の relay.py と同じく続ける。 ## 概要 @@ -11,13 +11,13 @@ Neural Development Factory の利用者は、リポジトリごとに方針の ## 用語 -定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`・`ndf-workflow`)。この文書で使う語は「開発ループ」「周回」「マイルストーンの持ち分」「課題の持ち分」「手元のランナーの枠」「区間」「区間の上限」「同時のジョブの数」「人の判断待ち」「リセット時刻」である。 +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`・`ndf-workflow`)。この文書で使う語は「開発ループ」「周回」「マイルストーンの持ち分」「課題の持ち分」「手元のマシンの枠」「区間」「区間の上限」「同時のジョブの数」「人の判断待ち」「リセット時刻」である。 ## コンテキスト | コンテキスト | 何の語が 1 つの意味に決まるか | | --- | --- | -| Neural Development Factory(`ndf-factory`) | 開発ループ(集約)・スプリントの実行・実行の枠・マイルストーンの持ち分・課題の持ち分・手元のランナーの枠・区間・区間の上限・同時のジョブの数 | +| Neural Development Factory(`ndf-factory`) | 開発ループ(集約)・スプリントの実行・実行の枠・マイルストーンの持ち分・課題の持ち分・手元のマシンの枠・区間・区間の上限・同時のジョブの数 | | NDF の開発ワークフロー(`ndf-workflow`) | 開発ループ(`loop.json`)・周回・ループの予算・止まる条件・人の判断待ち・`ndf-next`・リセット時刻 | `ndf-workflow` が上流、`ndf-factory` が下流の腐敗防止層である。Factory の開発ループは NDF のループの記録(`.ndf/loop/<名>/loop.json`)を読まず、ランナーが翻訳した進捗の事象(`loop.lap_closed`・`loop.stopped`)だけを読む。翻訳はランナーの `factory/runner/src/loop-file.ts` と `translate.ts` だけが持つ。`ndf-next` のブロックの読み方は NDF の契約の一覧が公開された言語として宣言し、ランナーは形を写す。 @@ -27,8 +27,8 @@ Neural Development Factory の利用者は、リポジトリごとに方針の | 要素 | 場所 | 責務 | | --- | --- | --- | | ループの制御 | `factory/api/src/runs/loops.ts`(`LoopControl`) | 始める・止める・予算・答え・次の周回の操作、`startNextLap`(周回の開始の判定とトランザクション)、`wakeWaitingLoops`(待っているループを古い順に起こす)、停止の後の処理が使うループの状態の写し | -| 実行の制御 | `factory/api/src/runs/control.ts`(`RunControl`) | 手動の開始の課題の持ち分・マイルストーンの照合・版の照合・リポジトリの同時の数。停止の後の処理で `loop.*` と `job.finished` の理由を読み、ループの写しと `awaiting_reset` を同じトランザクションへ入れる。見回りの再開の時刻と待っているループ。`claimLocalRunJob` の手元のランナーの枠 | -| 実行の表 | `factory/api/src/runs/table.ts`・`dynamo-table.ts`・`memory-table.ts` | ループの行・持ち分の行・手元のランナーの枠の行と、3 集約をまたぐ条件つきの書き込み | +| 実行の制御 | `factory/api/src/runs/control.ts`(`RunControl`) | 手動の開始の課題の持ち分・マイルストーンの照合・版の照合・リポジトリの同時の数。停止の後の処理で `loop.*` と `job.finished` の理由を読み、ループの写しと `awaiting_reset` を同じトランザクションへ入れる。見回りの再開の時刻と待っているループ。`claimLocalRunJob` の手元のマシンの枠 | +| 実行の表 | `factory/api/src/runs/table.ts`・`dynamo-table.ts`・`memory-table.ts` | ループの行・持ち分の行・手元のマシンの枠の行と、3 集約をまたぐ条件つきの書き込み | | 移行の埋め戻し | `factory/api/src/runs/backfill.ts` | 既存の終わりの状態でない実行を古い版として `legacyCount` と課題の持ち分へ写し、移行の済みの行を書く | | 上限の判定 | `factory/api/src/accounts/limits.ts`(`judgeLimits`) | 理由 `concurrent_repo_runs`(リポジトリの枠の数と登録の宣言) | | 取り込み | `factory/api/src/repositories/service.ts` | 登録の `maxConcurrentRuns` の宣言 | @@ -93,11 +93,11 @@ graph TD LF --> NDF MAIN --> RAPI MAIN --> ING - CAP -.->|手元のランナーの登録と心拍| RC + CAP -.->|手元のマシンの登録と心拍| RC READ --> ING ``` -ループの制御は既存の API の handler と、停止の知らせと見回りの Lambda の中で動く。手元のランナーは利用者の環境で、`max_jobs` の数までジョブを別の作業場所で並べて動かす。 +ループの制御は既存の API の handler と、停止の知らせと見回りの Lambda の中で動く。手元のマシンは利用者の環境で、`max_jobs` の数までジョブを別の作業場所で並べて動かす。 ## 仕様 @@ -107,12 +107,12 @@ graph TD | --- | --- | --- | --- | | 開発ループ | ループの制御(`LoopControl`) | ループ | 名前・選定の対象のマイルストーン・予算・状態・待ちの理由・止まった理由・人の判断待ちの写し・答え・締めた周回の和 | | スプリントの実行 | 実行の制御 | スプリントの実行(エンティティ: 実行ジョブ) | 属するループ・周回の番号・終わりの理由・再開の時刻 | -| 実行の枠 | 実行の制御 | アカウントの枠 | リポジトリの枠の古い版の数・マイルストーンの持ち分・課題の持ち分・手元のランナーの枠 | +| 実行の枠 | 実行の制御 | アカウントの枠 | リポジトリの枠の古い版の数・マイルストーンの持ち分・課題の持ち分・手元のマシンの枠 | | リポジトリの登録 | 取り込み | 登録 | 同時の実行の数の宣言 | - ほかの集約は識別子で参照する。ループは登録を `repositoryId`、今の実行を `currentRunId` で指し、実行はループを `loopId` で指す - 1 つの処理が書き換える集約は 1 つで、例外は「開発ループ・スプリントの実行・実行の枠」の 3 つを 1 つのトランザクションで書く周回の開始・実行の終わりへの移り・ループの取り消しである。ループの「今の実行」と実行の作成を別のトランザクションにすると、間で止まったときに見回りがもう 1 本を起こすためである。実行の制御は停止の後の処理でループの写しを同じトランザクションへ入れるが、書く中身はループの制御の関数が組む -- 持ち分と手元のランナーの枠は実行の枠の行である。取り合いの条件を、実行と同じトランザクションの条件つきの書き込みで守るためである(読んでから書くと同時の 2 本がどちらも通る) +- 持ち分と手元のマシンの枠は実行の枠の行である。取り合いの条件を、実行と同じトランザクションの条件つきの書き込みで守るためである(読んでから書くと同時の 2 本がどちらも通る) - DynamoDB のトランザクションは 100 件まで書け、最も多い手動の開始(実行 1・ジョブ 1・枠 2・課題の持ち分 20・マイルストーンの持ち分の照合が題ごとに 1 件で多くて 20 の 44 件)でも足りる ### 常に成り立つ条件 @@ -124,8 +124,8 @@ graph TD | I46 | 実行の枠 | 枠の数は終わりの状態でない実行の数に等しい。`between_laps` のループは枠の行を持たない | ループは枠の行を書かない。枠は実行の作成と終わりへの移りでだけ増減する | | I47 | 実行の枠 | 同じリポジトリで、1 つの課題を持つ終わりの状態でない実行は 1 つまで | 手動の開始が課題の持ち分の `attribute_not_exists` の書き込みと、課題のマイルストーンの持ち分の `attribute_not_exists` の照合を同じトランザクションで行う。前者で落ちれば 409 `issue_in_use`、後者で落ちれば 409 `issue_in_loop`。ループの周回へは、強い整合で読んだ持ち分の課題を `EXCLUDE_ISSUES` で渡す | | I48 | 実行の枠 | 並行を許す版の下限より古い NDF の版の実行がリポジトリにあれば、その実行は唯一である | リポジトリの枠の `legacyCount` と `count` を条件にし、落ちれば 409 `parallel_unsupported_version`(ループは待つ)。移行の済みの行が無い間は、すべての版を古い版として扱う | -| I49 | 実行の枠 | リポジトリの終わりの状態でない実行の数は、プランの同時の数と登録の宣言の小さい方以下 | `judgeLimits` が `concurrent_repo_runs` を理由に断る(ループは待つ) | -| I50 | 実行の枠 | 1 つの手元のランナーが `running` で持つ `local` のジョブの数は、そのランナーが申告した `max_jobs` 以下 | 受け取りが手元のランナーの枠を条件に書き、落ちれば `null`(渡さない) | +| I49 | 実行の枠 | リポジトリの終わりの状態でない実行の数は、料金プランの同時の数と登録の宣言の小さい方以下 | `judgeLimits` が `concurrent_repo_runs` を理由に断る(ループは待つ) | +| I50 | 実行の枠 | 1 つの手元のマシンが `running` で持つ `local` のジョブの数は、その手元のマシンが申告した `max_jobs` 以下 | 受け取りが手元のマシンの枠を条件に書き、落ちれば `null`(渡さない) | | I51 | スプリントの実行 | 1 つの実行ジョブが起動する区間は区間の上限以下 | ランナーが次の区間を起動せず `job.finished`(`stopped`・`section_limit`)で終える | | I52 | 開発ループ | `local` のループの次の周回は、利用者の「次の周回を始める」の操作か、設定 `LOCAL_LOOP_AUTOSTART=true` のときだけ起こる | 自動の起動は `waitReason` が `user_start` のループを飛ばす | | I53 | 開発ループ | ループの状態は、属する実行の終わり方と `loop.stopped` の条件から、下の「ループの状態の写し方」の表のとおりにだけ決まる | 表に無い組は `stopped`(`run_failed`)にする | @@ -151,7 +151,7 @@ I8・I9・I32〜I43 は [スプリントの実行と実行ジョブの状態](nd | E11 | 利用上限でジョブを終えた(`usage_limit`) | ランナー | 実行の制御(実行を `awaiting_reset`) | | E12 | リセット時刻の後に再開を起こした | 実行の制御(見回り) | ランナー(`START=resume`) | | E13 | 利用者がループを止めた(`cancelled`) | 実行の制御(画面の操作) | 実行の制御(`cancelSprintRun` と同じ形で今の実行を止める) | -| E14 | 手元のランナーが同時のジョブの数を申告した | 手元のランナー(登録と心拍) | 実行の制御(`claimLocalRunJob` の条件) | +| E14 | 手元のマシンが同時のジョブの数を申告した | 手元のマシン(登録と心拍) | 実行の制御(`claimLocalRunJob` の条件) | ### ループの状態 @@ -251,7 +251,7 @@ sequenceDiagram | 実行を `awaiting_reset`(`resumeAt`)にする | `job.finished` が `stopped`・`usage_limit` で、形態が `local`(I54。ほかの形態では `failed`) | | 実行の `finishReason` | `job.finished` に `reason` がある | | 課題の持ち分を消す(`sprintRunId` がその実行の行だけ)・`legacyCount` を減らす(実行の `legacy` が真のときだけ) | 実行が終わりの状態へ移る。実行を移す書き込みに読んだ時の `legacy` の値を条件として入れ(真・偽・`attribute_not_exists(legacy)`)、落ちたら実行と持ち分を読み直してやり直す。読んだ後に埋め戻しが `legacy` を真にしたとき、減算と削除を漏らさないためである | -| 手元のランナーの枠を減らす | ジョブを終わりへ移す最後の書き込み(`runnerId` がある) | +| 手元のマシンの枠を減らす | ジョブを終わりへ移す最後の書き込み(`runnerId` がある) | | ループの写し(上の表)・`lapsClosed` などの和・`pending`・持ち分の削除 | 実行が `loopId` を持つ。`loop.lap_closed` と `loop.stopped` をジョブの冪等の鍵の範囲で読む | トランザクションが通った後に、ループが `between_laps` なら `startNextLap`、枠が減ったなら `wakeWaitingLoops` を呼ぶ。どちらも落ちてよく、次の見回りが同じ結果で起こし直す。 @@ -316,11 +316,11 @@ sequenceDiagram | 6. 答えの書き込み | `LOOP_ANSWERS` の各組のうち、`loop.json` の人の判断待ちに答えの無い番号だけ `sprint-state.py loop answer <名> <番号> --approved \| --rejected [--pr <番号>]` を打つ | | 7. conductor | `/ndf:development-workflow --loop --milestones [--budget-laps …]`。子の環境に `NDF_LOOP_EXCLUDE=` を置く | -### 手元のランナーの同時のジョブの数 +### 手元のマシンの同時のジョブの数 -- `claimLocalRunJob(accountId, {runnerId, maxJobs})` は、そのランナーの手元のランナーの枠が `maxJobs` 未満のときだけ、ジョブを `running` にする書き込みと枠を 1 増やす書き込みを 1 回のトランザクションで行う(I50)。引数の 2 つ目が無い呼び出しは `max_jobs` 1・ランナーの識別子なしとして扱う -- 申告の値の保存は手元のランナーの登録の集約に属し、この仕様は受け取りの条件と申告の形と目安の関数を持つ -- 目安は `max(1, min(floor(CPU の数 × 3 / 4), floor(メモリの GiB / 5)))` である(2026-09-25 の実測、8 コア・30 GB で全体テスト 6 本から 1 本あたり 4/3 コア・5 GiB)。申告が目安を超えれば手元のランナーが標準エラーへ 1 行の警告を出し、申告はそのまま送る。手元の機械の資源を知るのは手元だけで、サービスは申告を切り詰めない +- `claimLocalRunJob(accountId, {runnerId, maxJobs})` は、その手元のマシンの枠が `maxJobs` 未満のときだけ、ジョブを `running` にする書き込みと枠を 1 増やす書き込みを 1 回のトランザクションで行う(I50)。引数の 2 つ目が無い呼び出しは `max_jobs` 1・ランナーの識別子なしとして扱う +- 申告の値の保存は手元のマシンの登録の集約に属し、この仕様は受け取りの条件と申告の形と目安の関数を持つ +- 目安は `max(1, min(floor(CPU の数 × 3 / 4), floor(メモリの GiB / 5)))` である(2026-09-25 の実測、8 コア・30 GB で全体テスト 6 本から 1 本あたり 4/3 コア・5 GiB)。申告が目安を超えれば手元のマシンが標準エラーへ 1 行の警告を出し、申告はそのまま送る。手元の機械の資源を知るのは手元だけで、サービスは申告を切り詰めない ### 予算の残りと費用 @@ -358,7 +358,7 @@ sequenceDiagram ## データ・設定 -実行の表の行・移行の埋め戻し・環境変数と、外部との契約(実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のランナーの申告・画面向けの経路)は [開発ループのデータと契約](ndf-factory-dev-loops-data-and-contracts.md) にある。 +実行の表の行・移行の埋め戻し・環境変数と、外部との契約(実行ジョブの指示・進捗の事象・NDF の受け渡しの形・手元のマシンの申告・画面向けの経路)は [開発ループのデータと契約](ndf-factory-dev-loops-data-and-contracts.md) にある。 ## エラー処理 @@ -383,7 +383,7 @@ sequenceDiagram - 下限より古い版の実行があるリポジトリで 2 本目が 409 `parallel_unsupported_version` になり、下限が設定に無ければ 2 本目が断られ、下限以上の版どうしは並ぶこと - 移行の済みの行が無い間は下限以上の版でも 2 本目が断られ、埋め戻しの後に既存の実行の課題を指す手動の開始が 409 `issue_in_use` になること。埋め戻しを 2 回流しても `legacyCount` が増えず、埋め戻す前に終わった実行で `legacyCount` が負にならないこと - `step` が `base-sync` の `attention` の行だけが `reason: base_conflict` の事象になること -- プランの値 3 で宣言 1 のリポジトリで 2 本目が `concurrent_repo_runs` で断られ、宣言が無ければプランの値で断られること +- 料金プランの値 3 で宣言 1 のリポジトリで 2 本目が `concurrent_repo_runs` で断られ、宣言が無ければ料金プランの値で断られること - `max_jobs` 1 のランナーへ 2 本目を渡さず、1 本目が終わると渡し、申告が無ければ 1 として扱うこと。目安が 8 コア・30 GiB で 6、2 コア・4 GiB で 1 になり、目安を超える申告で警告の 1 行が出て申告は変わらないこと - 答えると次のジョブの `LOOP_ANSWERS` に載り、ランナーが `loop answer` を打ち、答えの済んだ番号は打たないこと。ループの実行の `gate.reached` が `loop` を持つこと - `local` の周回の後にループが `user_start` で待ち、`next-lap` で起き、`LOCAL_LOOP_AUTOSTART=true` なら待たず、`managed`・`byok` は待たないこと @@ -400,7 +400,7 @@ sequenceDiagram - [リポジトリの登録・実行の制御・ランナー](ndf-factory-runs-and-runner.md) - [スプリントの実行と実行ジョブの状態・実行の表](ndf-factory-runs-state-and-data.md) - [契約の定義と進捗の受け口](ndf-factory-contracts-and-progress.md) -- [認証とアカウント・席・プラン](ndf-factory-accounts-and-plans.md) +- [認証とアカウント・席・料金プラン](ndf-factory-accounts-and-plans.md) - [画面の地図](ndf-factory-screen-map.md) - [開発ループのデータと契約](ndf-factory-dev-loops-data-and-contracts.md) - [NDF の入口の契約](ndf-entrypoint-contracts.md) diff --git a/docs/specifications/ndf-factory-runs-and-runner.md b/docs/specifications/ndf-factory-runs-and-runner.md index 1aab744d4..621e8bbf6 100644 --- a/docs/specifications/ndf-factory-runs-and-runner.md +++ b/docs/specifications/ndf-factory-runs-and-runner.md @@ -44,7 +44,7 @@ Neural Development Factory の利用者が自分の GitHub のリポジトリを ## 対象範囲 - **含む**: `factory/api/src/` の取り込み(`repositories/`)・キーの持ち込み(`credentials/`)・GitHub の口(`github/`)・実行の制御(`runs/`)・進捗の記録の読み出し(`progress/read.ts`)・画面向けの経路(`routes/factory.ts`)・ランナー向けの経路と停止の知らせと見回りの handler(`handlers/run-jobs.ts`・`handlers/run-control.ts`)・`index.ts` への export、`factory/runner/`(ランナーとイメージ)、`factory/web/src/pages/` の 3 画面(リポジトリ・キーの持ち込み・スプリントを始める)、`factory/contracts/openapi/runs.yaml`、`factory/.env.example` の 14 の変数の名前 -- **含まない**: 承認の依頼の保存と答えを返す経路の実装(#2173。経路の形だけを `runs.yaml` に置く)、手元のランナー(形態 ①)の登録・心拍・受け取りの経路(#2189。本書は渡す口の関数だけを持つ)、一覧と詳細の画面(#2172)、料金と台帳(#2174)、承認ゲートの外の問い合わせを止める hook と `ask.jsonl` の書き手(#2206)、ECS・S3・Secrets Manager・DynamoDB・EventBridge・GitHub App の作成などインフラの定義(private リポジトリ) +- **含まない**: 承認の依頼の保存と答えを返す経路の実装(経路の形だけを `runs.yaml` に置く)、手元のマシン(形態 ①)の登録・心拍・受け取りの経路(本書は渡す口の関数だけを持つ)、一覧と詳細の画面、料金と台帳、承認ゲートの外の問い合わせを止める hook と `ask.jsonl` の書き手、ECS・S3・Secrets Manager・DynamoDB・EventBridge・GitHub App の作成などインフラの定義(private リポジトリ) - **取り込むリポジトリの起点・テスト・配布・進め方の入力欄は持たない。** ランナーの中の NDF がそのリポジトリの `.ndf/` から読む(全体の契約の決定 8) - **止まった・失敗したスプリントの実行を同じ実行のまま続け直さない。** 新しいスプリントの実行として始める - プラグインの配布物・Skill・hook は変わらない。`plugins/` は `factory/` を参照しない(全体の契約の I1) @@ -76,7 +76,7 @@ Neural Development Factory の利用者が自分の GitHub のリポジトリを | 実行の表 | `factory/api/src/runs/table.ts`(`RunsTable`)・`dynamo-table.ts`・`memory-table.ts` | 条件つきの書き込みを操作の単位で持つ。`memory-table.ts` はテスト用の偽物 | | 実行基盤の口 | `factory/api/src/runs/platform.ts`(`RunnerPlatform`)・`ecs.ts`(`EcsRunnerPlatform`) | `RunTask`・`StopTask`・`DescribeTasks`、token の置き場の作成と削除、状態の置き場の署名つき URL。テストは偽物 | | 依存の組み立て | `factory/api/src/runs/deps.ts`(`factoryDepsFromEnv`・`ndfVersionFromMarketplace`) | 環境変数から依存を 1 回だけ作り、秘密を ARN から読む | -| 進捗の記録の読み出し | `factory/api/src/progress/read.ts`(`ProgressReader`) | `job.finished` を冪等の鍵で読む・月の `usage.recorded` の token を足す。読むだけで、保存の型に書き換えを足さない | +| 進捗の記録の読み出し | `factory/api/src/progress/read.ts`(`ProgressReader`) | `job.finished` を冪等の鍵で読む・月の `usage.recorded` の token を足す・スプリントの一覧と詳細の事象を読む([スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し))。読むだけで、保存の型に書き換えを足さない | | 画面向けの経路 | `factory/api/src/routes/factory.ts`(`registerFactory`) | 既存の API の handler(`handlers/api.ts`)に足す。どの経路も `resolveFromRequest` でアカウントを解決する | | ランナー向けの経路 | `factory/api/src/runs/runner-api.ts`(`createRunnerApiApp`)・`handlers/run-jobs.ts` | 実行ジョブの token で認証し、導入の token の渡し直しと状態の置き場の署名つき URL を返す | | 停止の知らせと見回りの handler | `factory/api/src/handlers/run-control.ts`(`handleRunControlEvent`) | HTTP を持たない。EventBridge の「ECS Task State Change」と 5 分ごとの起動を受ける | @@ -108,7 +108,7 @@ graph TD end ING[進捗の受け口 #2171] C2173["#2173 承認と通知"] - C2189["#2189 手元のランナー"] + C2189["#2189 手元のマシン"] PR --> REPO PK --> CRED PS --> CAND @@ -138,18 +138,18 @@ graph TD | 名前 | 使う側 | 入力 | 出力 | 失敗 | | --- | --- | --- | --- | --- | | `requestResume({sprintRunId, approvalRequestId})` | #2173(答えを保存した後) | 実行と依頼の識別子 | `Promise<{jobId}>`。`START=resume` のジョブを作り起動する | `ResumeRejectedError`(`code` が `not_found` / `not_awaiting_approval` / `sprint_run_busy`)。`sprint_run_busy` は前のジョブの停止を待っている間か、別の再開が先に通ったときで、呼ぶ側は時間をおいて呼び直す | -| `claimLocalRunJob(accountId)` | #2189(手元のランナーの受け取り) | アカウント | `Promise<{instruction: RunJobInstruction, token} \| null>`。そのアカウントの最も古い `pending` の `local` のジョブ(取り消しの要求の無い実行のもの)を `running` にし、指示と token を 1 回だけ返す | 保存の失敗は投げる | +| `claimLocalRunJob(accountId)` | #2189(手元のマシンの受け取り) | アカウント | `Promise<{instruction: RunJobInstruction, token} \| null>`。そのアカウントの最も古い `pending` の `local` のジョブ(取り消しの要求の無い実行のもの)を `running` にし、指示と token を 1 回だけ返す | 保存の失敗は投げる | | `reportLocalRunJobEnded(jobId)` | #2189(終わりか心拍の切れ) | ジョブ | `Promise`。停止の知らせと同じ処理 | 知らないジョブ・`local` でないジョブ・終わったジョブは何もしない | -| `cancelSprintRun(ctx, sprintRunId)` | 画面の経路と同じ処理 | アカウントの文脈と実行 | `Promise<{state: "cancelled" \| "running"}>` | `NotFoundError`(`sprint_run_not_found`。別のアカウントの実行を含む)・`ConflictError`(`already_finished`) | +| `cancelSprintRun(ctx, sprintRunId)` | 画面の経路と同じ処理 | アカウントの文脈と実行 | `Promise<{state: "cancelled" \| "running"}>` | `NotFoundError`(`sprint_run_not_found`。別のアカウントの実行を含む)・`ConflictError`(`already_finished`。main へマージした後の配布の実行をジョブの無いときに取り消すと `release_after_main`) | | `getSprintRun(sprintRunId)`・`getRegistration(accountId, repositoryId)` | 実行と登録を読む側 | 識別子 | 行か `null` | — | あわせて `RunControl`・`ResumeRejectedError`・`repoKey`(`owner/name` を小文字にした比較の鍵)と、`ResumeRejectedCode`・`RunControlConfig`・`RunControlDeps`・`RunJob`・`SprintRun`・`RunState`・`JobState` の型を export する。 -**`local` のジョブの token は置き場に書かず、`claimLocalRunJob` の応答で 1 回だけ返す。** 指示の形(`INGEST_TOKEN_SECRET_ARN` は必須)を保つため、その項目には `arn:aws:secretsmanager:local:000000000000:secret:ndf-factory/run-jobs/` の形の、実在しない ARN を置く。手元のランナーは環境変数 `INGEST_TOKEN` で token を直に渡し、ランナーはそれがあれば置き場を読まない。 +**`local` のジョブの token は置き場に書かず、`claimLocalRunJob` の応答で 1 回だけ返す。** 指示の形(`INGEST_TOKEN_SECRET_ARN` は必須)を保つため、その項目には `arn:aws:secretsmanager:local:000000000000:secret:ndf-factory/run-jobs/` の形の、実在しない ARN を置く。手元のマシンは環境変数 `INGEST_TOKEN` で token を直に渡し、ランナーはそれがあれば置き場を読まない。 ### 画面向けの経路 -機械可読な正は [`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"?}}`。 | 経路 | 誰が | 入力 | 成功 | 失敗 | | --- | --- | --- | --- | --- | @@ -164,8 +164,8 @@ graph TD | `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}]}`(新しい順 50 件) | 400(ID の形)・403 | -| `POST /accounts/{a}/sprint-runs/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished` | +| `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` | **持ち主だけの経路は、席を持つだけの利用者に他人のアカウントと同じ 403 `forbidden` を返す**(`requireOwner`)。チームのアカウントでは、リポジトリの登録と外し・認可・候補・キーの 3 つの操作は持ち主(`role` が `owner`)だけが、スプリントの開始・一覧・取り消しと課題の候補は席を持つ人が行う。 @@ -413,7 +413,7 @@ Workflows・Administration・Projects は持たない。Workflows は CI を書 - **ワークフローを変える課題は Factory では進められない。** App が Workflows の権限を持たないため push が断られ、ジョブは `failed` で終わる。手元の NDF で進める - **ランナーの中の NDF のボードの記録は失敗する。** App が Projects の権限を持たないためで、NDF はボードの失敗で工程を止めない - **持ち込んだキーを消すと、その `byok` の実行の次のジョブは起動の時点で置き場を読めずに `failed` になる。** 実行は開始の時点のキーの ARN を持ち(I39)、消した後に保存し直したキーは新しい ARN になるためである。動いているジョブは起動の時点で読んだ値で続ける -- **`local` の `pending` のジョブには 15 分の上限を掛けない。** 手元のランナーが受け取るまで枠を使い続ける。止めるには取り消す +- **`local` の `pending` のジョブには 15 分の上限を掛けない。** 手元のマシンが受け取るまで枠を使い続ける。止めるには取り消す - **承認の依頼の答えの経路(`GET /run-jobs/approval-requests/{id}/answer`)は #2173 が実装するまで無い。** それまで `resume` のジョブは答えを読めず `failed` で終わる - **実環境の形は、インフラが入った後のリリース後テストで確かめる。** EventBridge の ECS の知らせの形・GitHub App の利用者の認可の応答・2 本の同時の実行の登録から完了まで @@ -463,7 +463,7 @@ Workflows・Administration・Projects は持たない。Workflows は CI を書 - 実装: [#2248](https://github.com/devbasex/ai-plugins/pull/2248)・[#2250](https://github.com/devbasex/ai-plugins/pull/2250) - サービス全体の契約: [ndf-factory-service-boundary.md](ndf-factory-service-boundary.md) - 受け口と実行ジョブの token(#2171): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) -- 認証とアカウント・席・プランの使える量(#2170): [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) +- 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) - 経路の契約: [factory/contracts/openapi/runs.yaml](../../factory/contracts/openapi/runs.yaml) - サービスのディレクトリと API の入口: [factory/README.md](../../factory/README.md) - 代わりの判断を残す問い合わせの guard(#2206): [ndf-ask-guard.md](ndf-ask-guard.md)、記録の形: [plugins/ndf/scripts/lib/ask_ledger.py](../../plugins/ndf/scripts/lib/ask_ledger.py) diff --git a/docs/specifications/ndf-factory-runs-state-and-data.md b/docs/specifications/ndf-factory-runs-state-and-data.md index ddcc163aa..4cefc4fc5 100644 --- a/docs/specifications/ndf-factory-runs-state-and-data.md +++ b/docs/specifications/ndf-factory-runs-state-and-data.md @@ -32,13 +32,15 @@ stateDiagram-v2 `RUNNING` の知らせを取りこぼしたときは `starting` から終わりの状態や `awaiting_approval` へ直に移る。`running` への移りは `starting` のときだけ書く。 +Factory のオーガナイザーの配布の依頼と配布の実行では、`awaiting_approval` に理由(`awaitingReason`)が付く。手動の実行が配布の依頼を出して終わると、理由 `release_requested` の `awaiting_approval` で枠を持ったまま依頼の答えを待つ(ループの実行は `done` で終わる)。配布の実行(目的 `release`)は枠を持たず、配布済みなら `done`、承認ゲートか判断なら理由の無い `awaiting_approval`、それ以外の終わり方(失敗・打ち切り)でも終わりの状態にせず理由 `release_stalled` の `awaiting_approval` へ移る。配布の実行が終わりの状態の `stopped` になるのは、まとめた配布が差し戻されたときである。取り消しも、まとめた配布が main へマージした後なら終わりの状態にせず、取り消しの印を外して理由 `release_stalled` の `awaiting_approval` へ移す(続きから流して配布を仕上げる)。ジョブの無いときの取り消しは `release_after_main` で断る。 + **失敗の理由**(実行の `failureReason`): | 値 | いつ | | --- | --- | | `launch_failed` | token の置き場の作成か `RunTask` が失敗した。`pending` のまま 15 分を過ぎた | | `timeout` | 見回りが打ち切りの時間で止めた | -| `task_stopped` | ランナーの `job.finished` が無いままタスクが止まった(手元のランナーの終わりの知らせを含む) | +| `task_stopped` | ランナーの `job.finished` が無いままタスクが止まった(手元のマシンの終わりの知らせを含む) | | `job_failed` | ランナーが `job.finished`(`failed`)を送った。`local` 以外の形態の利用上限(`stopped`・`usage_limit`)も含む | | `seat_missing` | リセット時刻の後の再開で、始めた利用者が席を持たないか、形態が `local` でなくなっていた | @@ -108,7 +110,7 @@ erDiagram | スプリントの実行 | `RUN#` | `RUN` | `ACCOUNT#` / `RUN#` | | 実行ジョブ | `JOB#` | `JOB` | `ACTIVE_JOBS` / `#`(終わりの状態でない間だけ。終わりへ移すときに外す) | -列の名前は `factory/api/src/runs/model.ts` の型の名前のまま(camelCase)である。#2347 が足した行(開発ループ・課題とマイルストーンの持ち分・手元のランナーの枠・移行の済みの行)と列(実行の枠の `legacyCount`、実行の `legacy`・`resumeAt`・`loopId` など)は [開発ループの確定仕様の「実行の表の行」](ndf-factory-dev-loops-data-and-contracts.md#実行の表の行) にある。 +列の名前は `factory/api/src/runs/model.ts` の型の名前のまま(camelCase)である。#2347 が足した行(開発ループ・課題とマイルストーンの持ち分・手元のマシンの枠・移行の済みの行)と列(実行の枠の `legacyCount`、実行の `legacy`・`resumeAt`・`loopId` など)は [開発ループの確定仕様の「実行の表の行」](ndf-factory-dev-loops-data-and-contracts.md#実行の表の行) にある。 | 行 | 列 | 型 | 空 | 意味 | | --- | --- | --- | --- | --- | @@ -173,7 +175,7 @@ erDiagram | `USAGE_LIMIT_RETRY_SECONDS` | 制御 | リセット時刻を読めないときの待ち。既定 900 | | `RUNNER_SECTION_LIMIT` | ランナー | 1 つのジョブで起動する区間の上限。既定 20 | -API は要る変数が無ければ最初の要求の処理で失敗する。ランナーは指示の 16 項目と `RUNNER_SECTION_LIMIT` のほかに `NDF_MARKETPLACE`・`BEDROCK_REGION`・`RUNNER_WORK_DIR`・`NDF_MVV_STATE_DIR`(`ask.jsonl` の置き場)と、手元のランナーの `INGEST_TOKEN` を読む。 +API は要る変数が無ければ最初の要求の処理で失敗する。ランナーは指示の 16 項目と `RUNNER_SECTION_LIMIT` のほかに `NDF_MARKETPLACE`・`BEDROCK_REGION`・`RUNNER_WORK_DIR`・`NDF_MVV_STATE_DIR`(`ask.jsonl` の置き場)と、手元のマシンの `INGEST_TOKEN` を読む。 ## 関連リンク diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md index 039901c3e..67817d9cc 100644 --- a/docs/specifications/ndf-factory-screen-map.md +++ b/docs/specifications/ndf-factory-screen-map.md @@ -29,20 +29,20 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ | --- | --- | --- | --- | --- | | `/login` | ログイン | ログイン前 | あり | Google・GitHub でログイン([accounts-and-plans](ndf-factory-accounts-and-plans.md)「ログインの流れ」) | | `/callback` | ログインの戻り | ログイン前 | あり | code を token に換える(accounts-and-plans「ログインの流れ」) | -| `/account` | 自分のアカウント | 個人・管理者・席 | あり | 利用者の ID・個人のプランと実行の形態・使える量・チームの一覧・ログアウト(accounts-and-plans) | +| `/account` | 自分のアカウント | 個人・管理者・席 | あり | 利用者の ID・個人の料金プランと実行の形態・使える量・チームの一覧・ログアウト(accounts-and-plans) | | `/teams/new` | チームを作る | 個人 | あり | Team を作る(accounts-and-plans) | | `/accounts/:accountId` | チームのアカウント | 管理者・席 | あり | 席の追加・種類の変更・削除(管理者)・実行の形態(管理者)・使える量(accounts-and-plans) | | `/accounts/:accountId/repositories` | リポジトリ | 個人・管理者・席 | あり | 導入の案内・登録と外す(個人・管理者)・同時の実行の数の宣言(個人・管理者)・実行の状態([runs-and-runner](ndf-factory-runs-and-runner.md)「画面」) | | `/accounts/:accountId/model-credential` | キーの持ち込み | 個人・管理者 | あり | キーの保存と消去・外部 ID(runs-and-runner) | | `/accounts/:accountId/repositories/:repositoryId/sprints/new` | スプリントを始める | 個人・管理者・席 | あり | 課題の候補・選び直し・開始(runs-and-runner) | -| `/accounts/:accountId/sprints` | スプリントの一覧 | 個人・管理者・席 | あり(見本) | フェーズと状態・取り消し | -| `/accounts/:accountId/sprints/:sprintRunId` | スプリントの詳細 | 個人・管理者・席 | あり(見本) | 工程・PR・CI・承認ゲート・起票の依頼([issue-requests](ndf-factory-issue-requests.md)) | +| `/accounts/:accountId/sprints` | スプリントの一覧 | 個人・管理者・席 | あり | 今のフェーズ(5 つの棒)と今の工程・状態・状態の絞り込み・最後の動き([contracts-and-progress](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | +| `/accounts/:accountId/sprints/:sprintRunId` | スプリントの詳細 | 個人・管理者・席 | あり | フェーズの欄(仕上げに振り返りと棚卸し)・課題ごとの工程の記録・工程の詳細パネル・Pull Request と CI・承認ゲート・使用量(contracts-and-progress「スプリントの一覧と詳細の読み出し」)・起票の依頼([issue-requests](ndf-factory-issue-requests.md)) | | `/accounts/:accountId/repositories/:repositoryId/loops` | ループ | 個人・管理者・席 | あり | 開発ループの一覧(名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由)・始める | | `/accounts/:accountId/loops/:loopId` | ループの詳細 | 個人・管理者・席 | あり | 周回・周回の実行と区間の数・利用上限の再開の時刻・人の判断待ちと答え・予算の変更・止める・次の周回を始める | -| `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | 予定 | 順位の表と棚卸しの記録 | +| `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | あり | 順位の表と棚卸しの記録(読むだけ) | | 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 | -| 未定 | 請求と使用量 | 個人・管理者 | 予定 | プランの料金・使用量の台帳・決済 | -| 未定 | 手元のランナー | 個人・管理者 | 予定 | ランナーの登録・心拍 | +| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 | +| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 | ## ナビゲーション @@ -59,18 +59,19 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ ## 見本の画面 -スプリントの一覧と詳細は、部品だけで組み、ダミーデータで動く見本である。並べる項目・フェーズの集合・工程の詳細パネルは #2172 が置き換え、経路は変えない。 +スプリントの一覧と詳細とバックログは、部品だけで組み、ダミーデータでも動く。一覧と詳細は開いている間、画面の設定の間隔(環境変数 `FACTORY_POLL_INTERVAL_MS`、既定 30 秒)で読み直し、読み直しが失敗しても前の表示を残して失敗の時刻を出す。 | 状態 | 一覧 | 詳細 | | --- | --- | --- | | 読み込み中 | `LoadingState` | 同じ | -| 0 件・見つからない | `EmptyState`「スプリントの実行はまだありません」 | `EmptyState`「このスプリントの実行は見つかりません」 | +| 0 件 | `EmptyState`「スプリントの実行はまだありません」 | 工程が 0 件なら `EmptyState`「工程の記録はまだありません」 | | 失敗(5xx など) | `ErrorState` と「もう一度読む」 | 同じ | | 401 | token を消してログインへ | 同じ | -| 403 | 「このアカウントを見る権限がありません」 | 同じ | +| 403(無い実行も同じ) | 「このアカウントを見る権限がありません」 | 同じ | +| 読み直しの失敗 | 前の表示を残し、上に「読み直せませんでした(<時刻>)」 | 同じ | | オフライン | 枠の `ConnectionStatus` が出し、画面は最後の表示を残す | 同じ | -詳細は `Api` の `sprintRunDetails`(実行の ID の配列を受け、要素の配列を返す)で読む。本物の実装は詳細の経路がまだ無いため、アカウントの新しい順の 50 件から選ぶ。 +詳細は `Api` の `sprintRunDetails`、工程の中身は `stepDetails`、バックログは `backlogs` で読む(どれも ID の配列を受け、要素の配列を返す)。本物の実装は経路を 1 件ずつ読む。 ## ダミーで起動する @@ -86,6 +87,6 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ ## 関連リンク - [画面の部品](ndf-factory-ui-components.md) -- [認証とアカウント・席・プラン](ndf-factory-accounts-and-plans.md) +- [認証とアカウント・席・料金プラン](ndf-factory-accounts-and-plans.md) - [リポジトリの登録・スプリントの実行の制御・ランナー](ndf-factory-runs-and-runner.md) - [起票の依頼と起票の代行](ndf-factory-issue-requests.md) diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md index 4724a1056..05d31d582 100644 --- a/docs/specifications/ndf-factory-service-boundary.md +++ b/docs/specifications/ndf-factory-service-boundary.md @@ -137,15 +137,15 @@ graph TD | 課題 | 受け持つ範囲 | 提供する契約 | 使う契約 | | --- | --- | --- | --- | -| #2170 | Cognito の利用者プール(Google・GitHub)・アカウント・席・プランと使える量・実行の形態の値の画面と API | `resolveAccount(caller, accountId?)` が返す `account_id`(token には載せず、検証した token の利用者と経路 `/accounts/{account_id}/…` から解決する。経路に無ければ個人のアカウント)、使える量の問い合わせ(同時のスプリントの数・月の token の上限)、アカウントの実行の形態 | 無し | +| #2170 | Cognito の利用者プール(Google・GitHub)・アカウント・席・料金プランと使える量・実行の形態の値の画面と API | `resolveAccount(caller, accountId?)` が返す `account_id`(token には載せず、検証した token の利用者と経路 `/accounts/{account_id}/…` から解決する。経路に無ければ個人のアカウント)、使える量の問い合わせ(同時のスプリントの数・月の token の上限)、アカウントの実行の形態 | 無し | | #2171 | `factory/contracts/`・進捗の受け口・実行ジョブの token の検証 | 実行ジョブの指示・進捗の事象の種類と形・受け口の認証と応答・実行ジョブの token の検証(token から実行ジョブと `sprint_run_id`・`repo` を返し、無効なら拒む) | #2170 の `resolveAccount` が返す `account_id`、#2173 の保存済みの答え(`gate.answered` の照合) | | #2186 | GitHub App・リポジトリの登録・スプリントの実行と実行ジョブ・クラウドのランナー(形態 ② と ③)・持ち込んだキーの保存・実行ジョブの token の発行 | 実行の制御への再開の要求の受け口、実行ジョブを渡す口(#2189 が使う) | #2171 の `factory/contracts/`・進捗の受け口・token の検証、#2170 の `resolveAccount` が返す `account_id` と使える量と実行の形態、#2173 の承認の依頼の答えを読む経路 | -| #2172 | 一覧と詳細の画面・派生の形・フェーズの名前の集合 | 無し | 進捗の事象(`phase.changed`・`step.recorded`・`pr.updated`・`ci.updated`) | +| #2172 | 一覧・詳細・バックログの画面と読み出しの経路・派生の形・フェーズの名前の集合・ランナーの工程の中身の翻訳 | フェーズの名前の列挙(`factory/contracts/src/phases.ts`)、`step.recorded` の工程の中身の任意の項目 | 進捗の事象(`phase.changed`・`step.recorded`・`gate.reached`・`gate.answered`・`usage.recorded`・`decision.delegated`・`pr.updated`・`ci.updated`)、#2186 のスプリントの実行の行と GitHub の口の順位の表、#2170 の `resolveAccount` が返す `account_id` | | #2173 | 承認の依頼・Web と Slack の通知・Web からの承認 | `gate.answered`、承認の依頼の答えを読む経路、実行の制御への再開の要求 | `gate.reached`・`attention`、#2171 の実行ジョブの token の検証 | -| #2174 | プランの料金・使用量の台帳・決済の手段と提供元・Bedrock の使用量の帰属(形態 ③ だけ) | 請求の画面と API | `usage.recorded`、#2170 のプランと実行の形態 | -| #2189 | 手元のランナー(形態 ①)の登録・心拍・実行ジョブの受け取り | 無し | #2186 の実行ジョブを渡す口と実行ジョブの token、#2171 の `factory/contracts/`・進捗の受け口、#2170 の `resolveAccount` が返す `account_id` と実行の形態 | +| #2174 | 料金プランの料金・使用量の台帳・決済の手段と提供元・Bedrock の使用量の帰属(形態 ③ だけ) | 請求の画面と API | `usage.recorded`、#2170 の料金プランと実行の形態 | +| #2189 | 手元のマシン(形態 ①)の登録・心拍・実行ジョブの受け取り | 無し | #2186 の実行ジョブを渡す口と実行ジョブの token、#2171 の `factory/contracts/`・進捗の受け口、#2170 の `resolveAccount` が返す `account_id` と実行の形態 | -各課題が本節の外で決めること: `factory/` の TypeScript のパッケージの管理の道具は #2170 が npm の workspaces に決めた(規則は [factory/README.md の「パッケージの管理」](../../factory/README.md#パッケージの管理))。ランナーで使える CLI と実行ジョブの打ち切りの時間は #2186 が決めた(「実行の制御」)。`phase.changed` が運ぶフェーズの名前の集合は #2172、決済の提供元と Bedrock の請求との突き合わせは #2174 が決める。 +各課題が本節の外で決めること: `factory/` の TypeScript のパッケージの管理の道具は #2170 が npm の workspaces に決めた(規則は [factory/README.md の「パッケージの管理」](../../factory/README.md#パッケージの管理))。ランナーで使える CLI と実行ジョブの打ち切りの時間は #2186 が決めた(「実行の制御」)。`phase.changed` が運ぶフェーズの名前の集合は #2172 が 5 つに決め、一覧と詳細の派生の規則とともに [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し) にある。決済の提供元と Bedrock の請求との突き合わせは #2174 が決める。 ### 構成と言語 @@ -153,16 +153,16 @@ graph TD | 要素 | 責務 | 課題 | | --- | --- | --- | -| 画面 | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・プランと請求 | 各課題が各画面 | +| 画面 | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・料金プランと請求 | 各課題が各画面 | | API | 画面からの要求を受け、アカウントで認可して各集約の持ち主へ渡す | 各課題が自分の経路 | -| 認証とプラン | 利用者プールとアカウント・席・プランの使える量 | #2170 | +| 認証と料金プラン | 利用者プールとアカウント・席・料金プランの使える量 | #2170 | | 取り込み | GitHub App の導入とリポジトリの登録 | #2186 | -| 実行の制御 | スプリントの実行と実行ジョブの状態を持ち、ジョブを起動し終了を受ける。同時の数をプランの使える量で抑える。実行ジョブの token を発行する | #2186 | +| 実行の制御 | スプリントの実行と実行ジョブの状態を持ち、ジョブを起動し終了を受ける。同時の数を料金プランの使える量で抑える。実行ジョブの token を発行する | #2186 | | ランナー | 実行ジョブの中身。取り込み・NDF の導入・conductor の起動・翻訳・送信・状態の保存 | #2186 | | 進捗の受け口 | ランナー・GitHub の通知・API から進捗の事象を受け、出所ごとに認証して冪等に追記する。実行ジョブの token を検証する | #2171 | | 一覧と詳細 | 進捗の事象から一覧と詳細の読み出しの形を導く | #2172 | | 承認と通知 | 承認の依頼を作って知らせ(Web・Slack)、答えを実行の制御へ渡す | #2173 | -| 課金 | 使用量の事象を台帳へ積み、プランの料金に上乗せして請求する | #2174 | +| 課金 | 使用量の事象を台帳へ積み、料金プランの料金に上乗せして請求する | #2174 | ```mermaid graph TD @@ -180,7 +180,7 @@ graph TD APR[承認と通知] BILL[課金] end - AUTH[認証とプラン] + AUTH[認証と料金プラン] REPO[取り込み] WEB --> API API --> AUTH @@ -229,7 +229,7 @@ graph TD | 集約 | 持ち主 | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | -| アカウント | 認証とプラン(#2170) | アカウント | 席 | プラン・使える量 | +| アカウント | 認証と料金プラン | アカウント | 席 | 料金プラン・使える量 | | リポジトリの登録 | 取り込み(#2186) | 登録 | — | GitHub のリポジトリの識別子・GitHub App の導入の識別子 | | スプリントの実行 | 実行の制御(#2186) | スプリントの実行 | 実行ジョブ | NDF の版・スプリントの状態・ジョブの結果 | | 実行の枠 | 実行の制御(#2186) | アカウントの枠 | — | 終わりの状態でない数(アカウント全体・リポジトリごと) | @@ -246,7 +246,7 @@ graph TD | 形態 | 値 | ランナーの場所 | Claude をどう呼ぶか | サービスが持つ Claude の認証情報 | 月の token の上限 | token の課金 | 受け持つ課題 | | --- | --- | --- | --- | --- | --- | --- | --- | -| ① 手元のランナー | `local` | 利用者の環境 | 利用者がログインした `claude` | 無し | 掛けない | しない | #2189 | +| ① 手元のマシン | `local` | 利用者の環境 | 利用者がログインした `claude` | 無し | 掛けない | しない | #2189 | | ② キーの持ち込み | `byok` | ECS Fargate | 利用者の Anthropic API キーか、利用者の AWS アカウントの Bedrock(ロールを引き受ける) | 利用者のキーかロールの ARN(Secrets Manager) | 掛けない | しない | #2186 | | ③ サービスの Bedrock | `managed` | ECS Fargate | サービスの AWS アカウントの Bedrock(タスクの役割の権限) | 無し(役割の権限) | 掛ける | する(#2174) | #2186・#2174 | @@ -317,7 +317,7 @@ erDiagram ### 実行ジョブの指示 -実行の制御からランナーへ、コンテナの環境変数で渡す。形態 ① では、手元のランナーが受け取りの応答で同じ項目を受け取る(#2189)。 +実行の制御からランナーへ、コンテナの環境変数で渡す。形態 ① では、手元のマシンが受け取りの応答で同じ項目を受け取る。 | 入力 | 型・必須 | 意味 | | --- | --- | --- | @@ -345,8 +345,8 @@ erDiagram | 種類 | 出所 | `payload` | 主な受け手 | | --- | --- | --- | --- | | `job.started` | ランナー | NDF の版・`START` | #2172 | -| `phase.changed` | ランナー(`sprint.json` の計画と `progress.jsonl` の `step` の行) | フェーズの名前・課題 | #2172 | -| `step.recorded` | ランナー(`progress.jsonl` の `step`) | ステップ・終了コード・秒・要約・このステップが作った・更新した Pull Request(リポジトリと番号。任意) | #2172 | +| `phase.changed` | ランナー(`sprint.json` の計画と `progress.jsonl` の `step` の行) | フェーズの名前([スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し) の 5 つ)・課題 | #2172 | +| `step.recorded` | ランナー(`progress.jsonl` の `step`) | ステップ・終了コード・秒・要約・このステップが作った・更新した Pull Request(リポジトリと番号。任意)・工程の中身(フェーズ・課題・触ったファイル・コミットの範囲・決めたこと・出力。どれも任意) | #2172 | | `attention` | ランナー(`progress.jsonl` の `attention`) | 理由・要約 | #2172・#2173 | | `gate.reached` | ランナー(作業の報告の `判断が要る` と承認資料) | 種類(ゲート 1・ゲート 2・判断)・Pull Request の URL・承認資料の本文 | #2173 | | `gate.answered` | API(#2173) | 承認の依頼の識別子・種類・承認か差し戻しか(判断では無し)・答えた人のアカウント。本文は事象に載せない | #2171・#2172 | @@ -493,7 +493,7 @@ stateDiagram-v2 - 子課題: [#2170](https://github.com/devbasex/ai-plugins/issues/2170)・[#2171](https://github.com/devbasex/ai-plugins/issues/2171)・[#2172](https://github.com/devbasex/ai-plugins/issues/2172)・[#2173](https://github.com/devbasex/ai-plugins/issues/2173)・[#2174](https://github.com/devbasex/ai-plugins/issues/2174)・[#2186](https://github.com/devbasex/ai-plugins/issues/2186)・[#2189](https://github.com/devbasex/ai-plugins/issues/2189) - 設計 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) -- 認証とアカウント・席・プランの使える量(#2170): [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.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) - 境界の検査: [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) diff --git a/docs/specifications/ndf-parallel-organizer.md b/docs/specifications/ndf-parallel-organizer.md index d55f54921..d3d37b282 100644 --- a/docs/specifications/ndf-parallel-organizer.md +++ b/docs/specifications/ndf-parallel-organizer.md @@ -56,7 +56,9 @@ lroot2 の変更もまとめて出た。そのため lroot2 が出していた - **1 台の中に閉じる**: まとめるのは 1 台の中のレーンだけである。進行は 1 つの SQLite(DBOS)に閉じる。 Neural Development Factory の実行をまとめるのは Factory のオーガナイザー(https://github.com/devbasex/ai-plugins/issues/2439)で、 同じ層の別の実装として互いに排他にする。依頼の形は契約 `parallel-request.json` として公開してある。 - なお今の `parallel.py` は Factory の実行ジョブ(`NDF_FACTORY_RUN`)を見分けず、排他の検査は持たない + Factory の実行ジョブ(環境変数 `NDF_FACTORY_RUN`)では、`parallel.py` の `init`・`start`・`run` が終了コード 1 で断る。 + 外のオーガナイザーのレーン(環境変数 `NDF_ORGANIZER_DIR`)は、計画名の代わりに `--external` を付けて依頼と答えを置き場のファイルで受け渡す。 + 置き場の形は契約 `parallel-external.json`、手順は parallel.md の「外のオーガナイザーのレーン」にある - **モード**: オーガナイザーはモードを持たない。レーンの中のスプリントは今どおりモードを判定する - **扱わないもの**: `main` へのホットフィックス、動いている計画へのレーンの追加(新しい計画名で `init` し直す)、GitHub API の同時本数の枠 @@ -264,10 +266,13 @@ stateDiagram-v2 | `NDF_PARALLEL_POLL` | 60 | `inbox` と `resume` を待つ秒(テストで縮める) | | `NDF_PARALLEL_RELAY` | — | 統括役の CLI の打ち方の差し替え(テスト用) | | `NDF_RELAY_NAME`・`NDF_RELAY_DIR` | — | レーンの判定と役割のファイルの置き場(ラッパーが立てる) | +| `NDF_FACTORY_RUN` | — | Factory の実行ジョブの印。あれば `init`・`start`・`run` を断る | +| `NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE` | — | 外のオーガナイザーの置き場とレーンの名前。あれば副命令は `--external` で置き場のファイルを読み書きする | ### 副命令の終了コード 各副命令の意味と打つ場面は parallel.md と `parallel.py` の冒頭の表にある。ここでは終了コードだけを挙げる。 +`NDF_FACTORY_RUN` があるときの `init`・`start`・`run` は 1 で終わる。`--external` の副命令の終了コードは `lib/parallel_external_cli.py` の冒頭の表にある。 | 副命令 | 0 | 1 | 2 | 3 | ほか | | --- | --- | --- | --- | --- | --- | diff --git a/factory/README.md b/factory/README.md index 2bedaf9cc..46169eb03 100644 --- a/factory/README.md +++ b/factory/README.md @@ -21,7 +21,7 @@ CDK のスタック・ネットワーク・秘密の管理は別の private リ | ディレクトリ | 中身 | 作る Issue | | --- | --- | --- | | `contracts/` | 進捗の事象・実行ジョブの指示・受け口の応答の JSON Schema(`contracts/schemas/`)と、そこから生成した型(workspace `@ndf-factory/contracts`)。API の経路の形は `contracts/openapi/`(アカウントは `accounts.yaml`、進捗の受け口は `ingest.yaml`、スプリントの実行とランナー向けの経路は `runs.yaml`) | #2170・#2171 | -| `web/` | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・プランと請求の画面 | #2170・#2172〜#2174・#2186 | +| `web/` | ログイン・リポジトリの登録・スプリントの一覧と詳細・承認・料金プランと請求の画面 | #2170・#2172〜#2174・#2186 | | `api/` | 画面からの要求を受け、アカウントで認可して各集約の持ち主へ渡す処理 | #2170〜#2174・#2186 | | `runner/` | 実行ジョブのコンテナ。リポジトリの取り込み・NDF の導入・conductor の起動・進捗の事象の送信 | #2186 | @@ -60,13 +60,15 @@ TypeScript のコードは npm の workspaces で束ね、どの子課題も次 | `POST /ingest/api/events` | `api` | API Gateway の IAM 認可(SigV4)。Lambda が呼び出し元のロールを `INGEST_API_CALLER_ROLE_ARN` と照らす | | `POST /ingest/github` | `github` | `X-Hub-Signature-256`。Lambda が GitHub App の Webhook の秘密で確かめる。API Gateway の認可は付けない | -- `api/src/handlers/run-jobs.ts` の `handler`: ランナー向けの API(`/run-jobs/*`、`INGEST_URL` と同じ origin)。実行ジョブの token で認証し、導入の token の渡し直しと状態の置き場の署名つき URL を返す。API Gateway の認可は付けない +- `api/src/handlers/run-jobs.ts` の `handler`: ランナー向けの API(`/run-jobs/*`、`INGEST_URL` と同じ origin)。実行ジョブの token で認証し、導入の token の渡し直しと状態の置き場の署名つき URL と、配布の依頼の答え・まとめた配布の中身(`/run-jobs/organizer/*`)を返す。API Gateway の認可は付けない - `api/src/handlers/run-control.ts` の `handler`: HTTP を持たない。EventBridge の「ECS Task State Change」(ランナーのクラスタ)と 5 分ごとの起動(見回り)を受ける +Factory のオーガナイザー(`api/src/organizer/`)は新しい handler もインフラの部品も持たない。同じリポジトリで並行する実行の配布の依頼をまとめ、配布の実行を起こし、答えを範囲の実行へ返す処理を、実行の制御の停止の後の処理・再開の要求・見回りと画面向けの API の中で進める。状態は実行の表の行(`ORG#`・`ORGREQ#`・`ORGBATCH#`・`ORGLOG#`)で、見回りは GSI `gsi1` の鍵 `ORGANIZER_ACTIVE` で引く。 + 実行の表(`RUNS_TABLE_NAME`)は区分の鍵 `pk`・並びの鍵 `sk` と、GSI `gsi1`(区分の鍵 `gsi1pk`・並びの鍵 `gsi1sk`、射影は全部)を持つ。ランナーのイメージは `runner/Dockerfile` で、`factory/` を文脈にして作る。タスク定義は形態ごとに 2 つで、`managed`(`RUNNER_TASK_DEFINITION_ARN`)の役割だけが Bedrock を呼べ、`byok`(`RUNNER_BYOK_TASK_DEFINITION_ARN`)の役割はキーの置き場の読み出しと `AssumeRole` だけを持つ。どちらの役割も S3 の権限を持たない。 進捗の事象の表(`PROGRESS_EVENTS_TABLE_NAME`)は区分の鍵 `sprint_run_id`・並びの鍵 `order_key` と、区分の鍵 `pr_ref` の GSI `pr_ref-index`(射影に `sprint_run_id` を含める)を持つ。冪等の鍵の表(`PROGRESS_IDEMPOTENCY_TABLE_NAME`)は区分の鍵 `idempotency_key` だけを持ち、TTL を置かない。 -ほかの子課題は `api/src/index.ts` から export したものだけを使う。要求の利用者は `requireCaller`、アカウントの文脈は `resolveFromRequest`(関数としては `resolveAccount`)、使える量は `getLimits`・`judgeLimits`、プランの変更は `setPlan` で得る。実行ジョブの token は `signRunJobToken` で作り、受け口で `verifyRunJobToken` で確かめる。テストでは `createRunJobTokenKit` で鍵の組をメモリの中に作る。実行の制御は `job.finished` を `appendControlEvent` で直に書く。契約の型は `@ndf-factory/contracts` か `api/src/index.ts` から import する。 +ほかの子課題は `api/src/index.ts` から export したものだけを使う。要求の利用者は `requireCaller`、アカウントの文脈は `resolveFromRequest`(関数としては `resolveAccount`)、使える量は `getLimits`・`judgeLimits`、料金プランの変更は `setPlan` で得る。実行ジョブの token は `signRunJobToken` で作り、受け口で `verifyRunJobToken` で確かめる。テストでは `createRunJobTokenKit` で鍵の組をメモリの中に作る。実行の制御は `job.finished` を `appendControlEvent` で直に書く。契約の型は `@ndf-factory/contracts` か `api/src/index.ts` から import する。 全体の設計(構成要素・実行基盤・進捗の正本・構成要素の間の契約)は [docs/specifications/ndf-factory-service-boundary.md](../docs/specifications/ndf-factory-service-boundary.md) にある。 diff --git a/factory/api/src/accounts/limits.ts b/factory/api/src/accounts/limits.ts index 124e878e2..aada1b674 100644 --- a/factory/api/src/accounts/limits.ts +++ b/factory/api/src/accounts/limits.ts @@ -88,11 +88,11 @@ export function judgeLimits( current: current.runningSprintRuns, }); } - // リポジトリの同時の数は、プランの同時の数と登録の宣言の小さい方 + // リポジトリの同時の数は、料金プランの同時の数と登録の宣言の小さい方 if (current.runningRepoRuns !== undefined) { const declared = current.repoMaxRuns ?? null; const repoLimit = declared === null ? limits.concurrentSprintRuns : Math.min(limits.concurrentSprintRuns, declared); - // 宣言の無いリポジトリの上限はプランの値で、アカウントの上限と同時に当たる。理由を重ねない + // 宣言の無いリポジトリの上限は料金プランの値で、アカウントの上限と同時に当たる。理由を重ねない const accountHit = reasons.some((r) => r.quantity === "concurrent_sprint_runs"); if (!(current.runningRepoRuns < repoLimit) && (!accountHit || repoLimit < limits.concurrentSprintRuns)) { reasons.push({ quantity: "concurrent_repo_runs", limit: repoLimit, current: current.runningRepoRuns }); diff --git a/factory/api/src/accounts/model.ts b/factory/api/src/accounts/model.ts index a9d557d31..b8db8392d 100644 --- a/factory/api/src/accounts/model.ts +++ b/factory/api/src/accounts/model.ts @@ -76,7 +76,7 @@ export interface AccountContext { executionMode: ExecutionMode; } -// I13: 種類とプランの組 +// I13: 種類と料金プランの組 export function planFitsKind(kind: AccountKind, plan: Plan): boolean { return kind === "team" ? plan === "team" : PERSONAL_PLANS.includes(plan); } diff --git a/factory/api/src/accounts/service.test.ts b/factory/api/src/accounts/service.test.ts index 84f85f66e..2707d0c88 100644 --- a/factory/api/src/accounts/service.test.ts +++ b/factory/api/src/accounts/service.test.ts @@ -89,14 +89,14 @@ describe("席の変更と削除の競合", () => { }); describe("setPlan(E6、I13・I20・I21)", () => { - it("プランを変え、履歴を 1 つ足す", async () => { + it("料金プランを変え、履歴を 1 つ足す", async () => { const id = await personal(); await service.setPlan(id, "max"); expect(store.accounts.get(id)).toMatchObject({ plan: "max", version: 2 }); expect(store.history.at(-1)).toMatchObject({ type: "plan.changed", actor: "system", before: "free", after: "max" }); }); - it("知らないプランと、種類に合わない組は PlanError で、行も履歴も変わらない", async () => { + it("知らない料金プランと、種類に合わない組は PlanError で、行も履歴も変わらない", async () => { const id = await personal(); const team = await service.createTeam({ userId: "u-1", idp: "google" }, "t", "standard"); const before = store.history.length; @@ -119,7 +119,7 @@ describe("setPlan(E6、I13・I20・I21)", () => { expect(store.history.slice(before).map((h) => h.type)).toEqual(["plan.changed", "execution_mode.changed"]); }); - it("形態の書き込みが失敗すればプランも変わらない", async () => { + it("形態の書き込みが失敗すれば料金プランも変わらない", async () => { const id = await personal(); await service.setPlan(id, "pro"); await service.setExecutionMode(await service.resolveAccount({ userId: "u-1", idp: "google" }), "managed"); diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts index 0ddc18508..241a98f96 100644 --- a/factory/api/src/accounts/service.ts +++ b/factory/api/src/accounts/service.ts @@ -137,7 +137,7 @@ export class AccountService { const user = await this.ensureUser(caller); const personal = await this.resolveFor(user, caller); const out: (AccountContext & { name: string | null })[] = [{ ...personal, name: null }]; - // GSI の行は候補。外した席が GSI に残る間に名前やプランを返さないよう、元の席を強い整合で確かめる(I18) + // GSI の行は候補。外した席が GSI に残る間に名前や料金プランを返さないよう、元の席を強い整合で確かめる(I18) for (const accountId of await this.store.listMemberships(caller.userId)) { const seat = await this.store.getSeat(accountId, caller.userId); if (!seat) continue; @@ -256,7 +256,7 @@ export class AccountService { if (!account) throw new ForbiddenError(); if (account.executionMode === mode) return account; if (mode === "managed" && account.plan === "free") { - throw new ConflictError("managed_not_allowed_on_free", "Free のプランでは managed(サービスの Bedrock)を選べません"); + throw new ConflictError("managed_not_allowed_on_free", "Free の料金プランでは managed(サービスの Bedrock)を選べません"); } const at = this.stamp(); try { @@ -271,10 +271,10 @@ export class AccountService { throw new AccountUnavailableError(); } - // アカウントのプランを変える。#2174 が決済の後に呼ぶ内側の関数(前提 8、E6) + // アカウントの料金プランを変える。#2174 が決済の後に呼ぶ内側の関数(前提 8、E6) // free へ下げるとき形態が managed なら、同じトランザクションで local へ変える(I21) async setPlan(accountId: string, plan: Plan): Promise { - if (!isPlan(plan)) throw new PlanError("unknown_plan", `知らないプランです: ${String(plan)}`); + if (!isPlan(plan)) throw new PlanError("unknown_plan", `知らない料金プランです: ${String(plan)}`); for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { const account = await this.store.getAccount(accountId); if (!account) throw new PlanError("account_not_found", "アカウントがありません"); diff --git a/factory/api/src/github/app.ts b/factory/api/src/github/app.ts index 2ef705596..de0bb5f86 100644 --- a/factory/api/src/github/app.ts +++ b/factory/api/src/github/app.ts @@ -8,6 +8,8 @@ import { type GitHubPort, type InstallationToken, type IssueSummary, + type PullByHead, + type PullHead, type ReachableRepository, type RepositoryVisibility, } from "./port.ts"; @@ -107,6 +109,34 @@ export class GitHubApp implements GitHubPort { return repo.private === false ? "public" : "private"; } + async branchHead(installationId: number, repositoryId: number, fullName: string, branch: string): Promise { + const token = await this.tokenFor(installationId, repositoryId); + if (!token) return null; + const ref = await this.get(`/repos/${fullName}/git/ref/heads/${encodeURIComponent(branch)}`, token); + const sha = (ref?.object as { sha?: unknown } | undefined)?.sha; + return typeof sha === "string" ? sha : null; + } + + async listOpenPullHeads(installationId: number, repositoryId: number, fullName: string, base: string): Promise { + const token = await this.tokenFor(installationId, repositoryId); + if (!token) return []; + const pulls = await this.getAllPages(`/repos/${fullName}/pulls?state=open&base=${encodeURIComponent(base)}`, token, null); + return pulls.map((p) => ({ head: ((p.head as { ref?: unknown } | undefined)?.ref as string) ?? "", url: p.html_url as string })); + } + + async findPullByHead(installationId: number, repositoryId: number, fullName: string, head: string, base: string): Promise { + const token = await this.tokenFor(installationId, repositoryId); + if (!token) return null; + const owner = fullName.split("/")[0]!; + const q = new URLSearchParams({ state: "all", head: `${owner}:${head}`, base, per_page: "10" }); + const pulls = (await this.getList(`/repos/${fullName}/pulls?${q}`, token)) ?? []; + const merged = pulls.find((p) => typeof p.merged_at === "string"); + const open = pulls.find((p) => p.state === "open"); + const pick = merged ?? open ?? pulls[0]; + if (!pick) return null; + return { state: pick === merged ? "merged" : pick.state === "open" ? "open" : "closed", url: pick.html_url as string }; + } + // リポジトリ 1 つと permissions の範囲だけに絞った導入の token。導入が届かなければ null private async scopedToken(installationId: number, repositoryId: number, permissions: Record): Promise { const res = await this.http(`${this.api}/app/installations/${installationId}/access_tokens`, { diff --git a/factory/api/src/github/port.ts b/factory/api/src/github/port.ts index 11bdb1529..e38a8e08a 100644 --- a/factory/api/src/github/port.ts +++ b/factory/api/src/github/port.ts @@ -68,6 +68,23 @@ export interface GitHubPort { createIssue(installationId: number, repositoryId: number, issue: { title: string; body: string }): Promise; // 公開範囲(metadata: read だけの token で読む)。導入が届かなければ null repositoryVisibility(installationId: number, repositoryId: number): Promise; + // ---- Factory のオーガナイザー(#2439)。読むだけ ---- + // 枝の先頭のコミット。枝が無いか導入が届かなければ null + branchHead(installationId: number, repositoryId: number, fullName: string, branch: string): Promise; + // base 宛ての開いている Pull Request の head の枝と URL + listOpenPullHeads(installationId: number, repositoryId: number, fullName: string, base: string): Promise; + // head の枝から base 宛ての Pull Request(開いている・マージ済み)。無ければ null + findPullByHead(installationId: number, repositoryId: number, fullName: string, head: string, base: string): Promise; +} + +export interface PullHead { + head: string; + url: string; +} + +export interface PullByHead { + state: "open" | "merged" | "closed"; + url: string; } // マイルストーンの名前の形(<2 桁の連番> <主題>) diff --git a/factory/api/src/handlers/api.test.ts b/factory/api/src/handlers/api.test.ts index b7628b781..b313eb9be 100644 --- a/factory/api/src/handlers/api.test.ts +++ b/factory/api/src/handlers/api.test.ts @@ -227,7 +227,7 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { expect(res.status).toBe(409); expect((await res.json()).error).toEqual({ code: "managed_not_allowed_on_free", - message: "Free のプランでは managed(サービスの Bedrock)を選べません", + message: "Free の料金プランでは managed(サービスの Bedrock)を選べません", }); expect(api.store.accounts.get(id)?.executionMode).toBe("local"); expect(api.store.history.length).toBe(before); diff --git a/factory/api/src/handlers/run-jobs.ts b/factory/api/src/handlers/run-jobs.ts index a86d63548..e19fa95ab 100644 --- a/factory/api/src/handlers/run-jobs.ts +++ b/factory/api/src/handlers/run-jobs.ts @@ -8,6 +8,6 @@ import { runJobTokenVerifierFromEnv } from "../progress/run-job-token.ts"; export const handler = handle( createRunnerApiApp(() => { const d = factoryDepsFromEnv(); - return { verifier: runJobTokenVerifierFromEnv(), control: d.control, table: d.table, github: d.github, platform: d.platform }; + return { verifier: runJobTokenVerifierFromEnv(), control: d.control, table: d.table, github: d.github, platform: d.platform, organizer: d.organizer }; }), ); diff --git a/factory/api/src/index.ts b/factory/api/src/index.ts index f0419ed2b..65ec4f550 100644 --- a/factory/api/src/index.ts +++ b/factory/api/src/index.ts @@ -31,7 +31,7 @@ export function resolveAccount(caller: Caller, accountId?: string): Promise { return depsFromEnv().accounts.setPlan(accountId, plan); } @@ -84,8 +84,9 @@ export type { ResumeRejectedCode, RunControlConfig, RunControlDeps } from "./run export type { RunJob, SprintRun, RunState, JobState } from "./runs/model.ts"; export { repoKey } from "./progress/github.ts"; -// 承認待ちのスプリントの実行に resume の実行ジョブを作り起動する。承認待ちでない・動くジョブがある → ResumeRejectedError -export function requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string }> { +// 承認待ちのスプリントの実行に resume の実行ジョブを作り起動する。承認待ちでない・動くジョブがある → ResumeRejectedError。 +// 配布の実行(#2439)への差し戻しはジョブを起こさず jobId が null +export function requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string | null }> { return factoryDepsFromEnv().control.requestResume(input); } @@ -94,7 +95,7 @@ export function claimLocalRunJob(accountId: string) { return factoryDepsFromEnv().control.claimLocalRunJob(accountId); } -// 手元のランナーの終わりか心拍の切れ。停止の知らせと同じ処理 +// 手元のマシンの終わりか心拍の切れ。停止の知らせと同じ処理 export function reportLocalRunJobEnded(jobId: string): Promise { return factoryDepsFromEnv().control.reportLocalRunJobEnded(jobId); } diff --git a/factory/api/src/organizer/control.test.ts b/factory/api/src/organizer/control.test.ts new file mode 100644 index 000000000..1985d6c17 --- /dev/null +++ b/factory/api/src/organizer/control.test.ts @@ -0,0 +1,558 @@ +// Factory のオーガナイザー(#2439)の振る舞い。表と進捗の記録はメモリに置き、ランナーの事象は記録へ直に足す +import { beforeEach, describe, expect, it } from "vitest"; +import { validateRunJobInstruction } from "@ndf-factory/contracts"; +import { ApiError } from "../errors.ts"; +import { appendEvent } from "../progress/append.ts"; +import { accountContext, createRunsHarness, TEST_CONFIG, type RunsHarness } from "../testing/runs.ts"; +import { ResumeRejectedError } from "../runs/control.ts"; +import { laneOf } from "./model.ts"; + +const REPO_ID = 101; +const REPO = "org-a/repo-a"; +const CRED_ARN = "arn:aws:secretsmanager:us-east-1:111111111111:secret:ndf-factory/model-credentials/u-1"; +let h: RunsHarness; +let n = 0; + +beforeEach(async () => { + h = await createRunsHarness({ ...TEST_CONFIG, parallelMinVersion: "10.0.0" }); + h.table.migrated = true; + h.github.installations.set(9, new Map([[REPO_ID, REPO]])); + h.github.openIssues.set(REPO, [1, 2, 3, 4].map((number) => ({ number, title: `t${number}` }))); + h.github.heads.set(`${REPO}#develop`, "aaaa111"); + await h.table.putRegistration({ accountId: "acc-1", repositoryId: REPO_ID, fullName: REPO, installationId: 9, registeredBy: "user-1", registeredAt: "2026-11-01T00:00:00Z" }); + await h.table.putCredential({ accountId: "acc-1", kind: "anthropic_api_key", secretArn: CRED_ARN, updatedAt: "2026-11-01T00:00:00Z" }); +}); + +async function codeOf(p: Promise): Promise { + try { + await p; + } catch (err) { + if (err instanceof ApiError || err instanceof ResumeRejectedError) return err.code; + throw err; + } + return "ok"; +} + +const at = () => h.clock.now.toISOString(); + +async function event(sprintRunId: string, jobId: string | null, type: string, payload: Record, key?: string, source = "runner") { + await appendEvent(h.progress, { + sprint_run_id: sprintRunId, + idempotency_key: key ?? `${jobId}:${++n}`, + type, + at: at(), + source, + job_id: jobId, + schema_version: 1, + payload, + } as never); +} + +async function finish(sprintRunId: string, jobId: string, result: "gate" | "done" | "failed", reason?: string) { + await event(sprintRunId, jobId, "job.finished", { result, summary: "s", ...(reason ? { reason } : {}) }, `${jobId}:finished`); + await h.control.onTaskStateChange(jobId, "STOPPED"); +} + +const runOf = async (id: string) => (await h.table.getRun(id))!; +const startRun = async (issue: number) => (await h.control.startSprintRun(accountContext(), { repositoryId: REPO_ID, issues: [issue] })).sprint_run_id; + +// 手動の実行が配布の依頼を出して答えを待つ(I65) +async function requestRelease(sprintRunId: string, prs: number[], requestId = `run-${sprintRunId}-r${++n}`) { + const run = await runOf(sprintRunId); + const jobId = run.currentJobId!; + await event(sprintRunId, jobId, "release.requested", { request_id: requestId, lane: `run-${sprintRunId}`, prs, issues: run.issues, ndf_version: "10.27.16", requested_at: at() }, requestId); + await finish(sprintRunId, jobId, "gate", "release_requested"); + return requestId; +} + +const currentBatch = async () => { + const root = await h.table.getOrganizer("acc-1", REPO_ID); + return root?.currentSeq ? h.table.getBatch("acc-1", REPO_ID, root.currentSeq) : null; +}; + +// 配布の実行のジョブが承認資料をそろえて承認ゲート 2 で止まる(auto なら MVV 判定が通して続ける) +async function present(k: 1 | 2, sha: string, prs: number[], opts: { auto?: boolean } = {}) { + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + const jobId = run.currentJobId!; + await event(run.sprintRunId, jobId, "release.presented", { seq: batch.seq, k, sha, version: "10.28.0", prs, lanes: batch.lanes.map((l) => ({ lane: l.lane, requested: l.requestIds.length > 0 })), added_prs: [], auto: opts.auto ?? false }, `${run.sprintRunId}:presented:${k}`); + if (opts.auto) return { run, jobId, batch }; + await event(run.sprintRunId, jobId, "gate.reached", { kind: "gate2", pr_url: null, material: "資料" }); + await finish(run.sprintRunId, jobId, "gate"); + return { run, jobId, batch }; +} + +async function answer(releaseRunId: string, decision: "approved" | "rejected", approvalRequestId = `ar-${++n}`) { + await event(releaseRunId, null, "gate.answered", { approval_request_id: approvalRequestId, kind: "gate2", decision, answered_by_account_id: "acc-1" }, `api:${approvalRequestId}`, "api"); + return h.control.requestResume({ sprintRunId: releaseRunId, approvalRequestId }); +} + +async function settle(releaseRunId: string, prs: number[], sha = "aaaa111") { + const run = await runOf(releaseRunId); + const jobId = run.currentJobId!; + const seq = run.releaseSeq!; + await event(releaseRunId, jobId, "release.progress", { seq, stage: "main_merged" }, `${releaseRunId}:progress:main_merged`); + await event(releaseRunId, jobId, "release.settled", { seq, result: "released", version: "10.28.0", prod_pr_url: `https://github.com/${REPO}/pull/900`, sha, prs, lanes: [] }, `${releaseRunId}:settled:released`); + await finish(releaseRunId, jobId, "done"); +} + +const answeredOf = (sprintRunId: string) => h.progress.listRun(sprintRunId).filter((e) => e.type === "release.answered"); +const instructionOf = (jobId: string) => h.platform.taskOf(jobId)![1].instruction; + +describe("配布のまとめ(受け入れ条件 1・2・3・5・6、I56・I57・I61・I64)", () => { + it("同じリポジトリの 2 本の依頼で、まとめた配布 1 つ・配布の実行 1 本・本番の Pull Request 1 本になり、2 本とも同じ答えを持つ", async () => { + const a = await startRun(1); + const b = await startRun(2); + await requestRelease(a, [11]); + // 1 本目の依頼で、他を待たずに組む(AC 2) + const batch1 = (await currentBatch())!; + expect(batch1.seq).toBe(1); + await requestRelease(b, [12]); + // 流れている間の依頼は組まずに残る(AC 5・I56) + expect((await h.table.listBatches("acc-1", REPO_ID, 10)).length).toBe(1); + const release = await runOf(batch1.releaseRunId); + expect(release).toMatchObject({ purpose: "release", releaseSeq: 1, state: "starting" }); + expect(release.sprintRunId).not.toBe(a); + expect(release.sprintRunId).not.toBe(b); + const instruction = instructionOf(release.currentJobId!); + expect(validateRunJobInstruction(instruction).ok).toBe(true); + expect(instruction).toMatchObject({ START: "release", RELEASE_SEQ: "1", RELEASE_STEP: "present", NDF_VERSION: "10.27.16" }); + + // 配布の実行の承認資料は、作る時点の develop(2 本の変更を含む)で決まる(AC 3) + await present(1, "aaaa111", [11, 12]); + expect((await currentBatch())!.state).toBe("presented"); + const resumed = await answer(release.sprintRunId, "approved"); + expect(instructionOf(resumed.jobId!)).toMatchObject({ START: "release", RELEASE_STEP: "confirm:1" }); + await settle(release.sprintRunId, [11, 12]); + + const batches = await h.table.listBatches("acc-1", REPO_ID, 10); + expect(batches).toHaveLength(1); + expect(batches[0]).toMatchObject({ state: "released", result: { version: "10.28.0", prodPrUrl: `https://github.com/${REPO}/pull/900` } }); + for (const id of [a, b]) { + expect(answeredOf(id).map((e) => e.payload)).toEqual([expect.objectContaining({ seq: 1, result: "released", version: "10.28.0", prod_pr_url: `https://github.com/${REPO}/pull/900` })]); + // 手動の実行は答えで再開する + const run = await runOf(id); + expect(run.state).toBe("starting"); + expect(instructionOf(run.currentJobId!)).toMatchObject({ START: "resume", RELEASE_REQUEST_ID: expect.stringMatching(/^run-/) }); + } + expect(await runOf(release.sprintRunId)).toMatchObject({ state: "done" }); + expect((await h.table.getOrganizer("acc-1", REPO_ID))!.currentSeq).toBeNull(); + }); + + it("配布の実行は実行の枠を持たない(I64)", async () => { + const a = await startRun(1); + const before = await h.table.getSlots("acc-1", REPO_ID); + await requestRelease(a, [11]); + expect(await h.table.getSlots("acc-1", REPO_ID)).toEqual(before); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + await settle(batch.releaseRunId, [11]); + expect(await h.table.getSlots("acc-1", REPO_ID)).toEqual(before); + }); + + it("手動の実行は依頼の後 awaiting_approval(release_requested)で枠を持ち、承認の依頼の答えでは再開しない(AC 7・I65)", async () => { + const a = await startRun(1); + const id = await requestRelease(a, [11]); + expect(await runOf(a)).toMatchObject({ state: "awaiting_approval", awaitingReason: "release_requested", releaseRequestId: id }); + expect((await h.table.getSlots("acc-1", REPO_ID)).repository).toBe(1); + expect(await codeOf(h.control.requestResume({ sprintRunId: a, approvalRequestId: "ar-x" }))).toBe("not_awaiting_approval"); + }); + + it("流れている間に届いた依頼は、配布済みの後に次のまとめた配布になる(AC 5)", async () => { + const a = await startRun(1); + const b = await startRun(2); + await requestRelease(a, [11]); + const first = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await requestRelease(b, [12]); + await answer(first.releaseRunId, "approved"); + await settle(first.releaseRunId, [11]); + const second = (await currentBatch())!; + expect(second.seq).toBe(2); + expect(second.lanes.find((l) => l.requestIds.length > 0)!.sprintRunId).toBe(b); + expect(answeredOf(b)).toHaveLength(0); + }); + + it("組む書き込みが落ちれば、まとめた配布も配布の実行も無い(AC 6・I57)", async () => { + const a = await startRun(1); + h.table.failNextCompose = true; + await requestRelease(a, [11]); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(0); + expect([...h.table.runs.values()].filter((r) => r.purpose === "release")).toHaveLength(0); + // 見回りが続きから組む + await h.control.sweep(); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(1); + expect([...h.table.runs.values()].filter((r) => r.purpose === "release")).toHaveLength(1); + }); + + it("同時の 2 つの tick で組まれるのは 1 つ(I56)", async () => { + const a = await startRun(1); + h.table.failNextCompose = true; + await requestRelease(a, [11]); + await Promise.all([h.organizer.tick("acc-1", REPO_ID), h.organizer.tick("acc-1", REPO_ID)]); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(1); + expect([...h.table.runs.values()].filter((r) => r.purpose === "release")).toHaveLength(1); + }); +}); + +describe("答えと出し直し(受け入れ条件 4、I60)", () => { + it("提示 1 への承認で develop が進んでいれば represent、提示 2 への答えでは develop を読まずに confirm:2", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + h.github.heads.set(`${REPO}#develop`, "bbbb222"); + const r1 = await answer(batch.releaseRunId, "approved"); + expect(instructionOf(r1.jobId!)).toMatchObject({ RELEASE_STEP: "represent" }); + expect((await currentBatch())!.state).toBe("represented"); + await present(2, "bbbb222", [11, 13]); + h.github.heads.set(`${REPO}#develop`, "cccc333"); + const reads = h.github.headReads.length; + const r2 = await answer(batch.releaseRunId, "approved"); + expect(h.github.headReads.length).toBe(reads); + expect(instructionOf(r2.jobId!)).toMatchObject({ RELEASE_STEP: "confirm:2" }); + expect((await currentBatch())!).toMatchObject({ state: "confirmed", confirmedK: 2 }); + expect((await currentBatch())!.presentations.map((p) => p.sha)).toEqual(["aaaa111", "bbbb222"]); + }); + + it("MVV 判定が通した提示は承認の依頼を作らずに確定する(E12)", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const { run } = await present(1, "aaaa111", [11], { auto: true }); + await h.organizer.tick("acc-1", REPO_ID); + expect((await currentBatch())!).toMatchObject({ state: "confirmed", confirmedK: 1 }); + await settle(run.sprintRunId, [11]); + expect(answeredOf(a)).toHaveLength(1); + }); + + it("差し戻しはジョブを起こさず、最後の提示の範囲の依頼へ rejected を返す(I61)", async () => { + const a = await startRun(1); + const b = await startRun(2); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await requestRelease(b, [12]); + const r = await answer(batch.releaseRunId, "rejected"); + expect(r.jobId).toBeNull(); + expect(await h.table.getBatch("acc-1", REPO_ID, 1)).toMatchObject({ state: "rejected" }); + expect(await runOf(batch.releaseRunId)).toMatchObject({ state: "stopped" }); + expect(answeredOf(a).map((e) => e.payload)).toEqual([expect.objectContaining({ result: "rejected" })]); + // 範囲に入らない依頼は次のまとめた配布になる + expect(answeredOf(b)).toHaveLength(0); + expect((await currentBatch())!.seq).toBe(2); + }); + + it("提示 1 への承認の再送は、提示 2 の承認にしない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + h.github.heads.set(`${REPO}#develop`, "bbbb222"); + await answer(batch.releaseRunId, "approved", "ar-first"); + await present(2, "bbbb222", [11, 13]); + expect(await codeOf(h.control.requestResume({ sprintRunId: batch.releaseRunId, approvalRequestId: "ar-first" }))).toBe("not_presented"); + expect((await currentBatch())!).toMatchObject({ state: "presented", confirmedK: null }); + }); + + it("確定した後に本番のプランの中の判断で止まったら、承認ゲート 2 の答えの再送で本番のプランを流し直さない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + h.github.heads.set(`${REPO}#develop`, "aaaa111"); + await present(1, "aaaa111", [11]); + const r = await answer(batch.releaseRunId, "approved", "ar-ok"); + expect(instructionOf(r.jobId!)).toMatchObject({ RELEASE_STEP: "confirm:1" }); + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "gate.reached", { kind: "decision", pr_url: null, material: "判断" }); + await finish(run.sprintRunId, run.currentJobId!, "gate"); + expect(await codeOf(h.control.requestResume({ sprintRunId: batch.releaseRunId, approvalRequestId: "ar-ok" }))).toBe("not_presented"); + }); + + it("提示の前の判断への答えは、続き(continue)でなく提示のプランから流し直す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "gate.reached", { kind: "decision", pr_url: null, material: "判断" }); + await finish(run.sprintRunId, run.currentJobId!, "gate"); + await event(run.sprintRunId, null, "gate.answered", { approval_request_id: "ar-d", kind: "decision", answered_by_account_id: "acc-1" }, "api:ar-d", "api"); + const r = await h.control.requestResume({ sprintRunId: run.sprintRunId, approvalRequestId: "ar-d" }); + expect(instructionOf(r.jobId!)).toMatchObject({ RELEASE_STEP: "present" }); + }); +}); + +describe("待ち(受け入れ条件 8、I62)", () => { + it("Factory の外の本番の Pull Request が開いている間は組まず、待ちの理由を残し、閉じた後の見回りで組む", async () => { + h.github.pulls.set(REPO, [{ head: "release/v10.27.9", base: "main", state: "open", url: `https://github.com/${REPO}/pull/77` }]); + const a = await startRun(1); + await requestRelease(a, [11]); + expect(await currentBatch()).toBeNull(); + const list = await h.organizer.list(accountContext(), REPO_ID); + expect(list.organizer).toEqual({ current_seq: null, wait_reason: "external_release_open", wait_detail: `https://github.com/${REPO}/pull/77` }); + expect((await h.table.listOrgLogs("acc-1", REPO_ID)).map((l) => l.kind)).toEqual(["waited"]); + h.github.pulls.set(REPO, [{ head: "release/v10.27.9", base: "main", state: "merged", url: `https://github.com/${REPO}/pull/77` }]); + await h.control.sweep(); + expect((await currentBatch())!.seq).toBe(1); + expect((await h.table.getOrganizer("acc-1", REPO_ID))!.waitReason).toBeNull(); + }); + + it("差し戻したまとめた配布が自分で開いた release/v<版> は外の本番と見なさず、次の配布を組む", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "rejected"); + h.github.pulls.set(REPO, [{ head: "release/v10.28.0", base: "main", state: "open", url: `https://github.com/${REPO}/pull/90` }]); + await requestRelease(await startRun(2), [12]); + expect((await currentBatch())!.seq).toBe(2); + }); + + it("head が develop の main 宛ての Pull Request でも待つ", async () => { + h.github.pulls.set(REPO, [{ head: "develop", base: "main", state: "open", url: `https://github.com/${REPO}/pull/78` }]); + await requestRelease(await startRun(1), [11]); + expect(await currentBatch()).toBeNull(); + }); + + it("依頼を出していない古い版の実行がある間は組まない。依頼を出して待つ古い版の実行は数えない", async () => { + h.table.migrated = false; + const a = await startRun(1); + expect((await runOf(a)).legacy).toBe(true); + await requestRelease(a, [11]); + // 古い版の実行は依頼を出して待っているので数えず、組む + expect((await currentBatch())!.seq).toBe(1); + }); + + it("まとめた配布が流れている間は古い版の実行を始めない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + expect(await currentBatch()).not.toBeNull(); + // 依頼を待つ実行を取り消してリポジトリの枠を空け、古い版の実行を始める + await h.control.cancelSprintRun(accountContext(), a); + h.table.migrated = false; + expect(await codeOf(startRun(2))).toBe("parallel_unsupported_version"); + }); + + it("照らした後に古い版の実行が数えに入れば、組む書き込みは落ちる(legacySeq)", async () => { + const a = await startRun(1); + h.table.failNextCompose = true; + await requestRelease(a, [11]); + // 古い版の実行が 1 本入って抜け、legacyCount は 0 に戻るが legacySeq は 1 進む + h.table.legacySeqs.set(`acc-1#${REPO_ID}`, 1); + const getSlots = h.table.getSlots.bind(h.table); + h.table.getSlots = async (acc, repo) => ({ ...(await getSlots(acc, repo)), legacySeq: undefined }); + await h.organizer.tick("acc-1", REPO_ID); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(0); + h.table.getSlots = getSlots; + await h.organizer.tick("acc-1", REPO_ID); + expect(await h.table.listBatches("acc-1", REPO_ID, 10)).toHaveLength(1); + }); +}); + +describe("止まった配布と再開(受け入れ条件 12・13、I58・I59・I63)", () => { + it("配布の実行が失敗すると止まった配布になり、main のマージの後は continue だけで、差し戻しは 409", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.progress", { seq: 1, stage: "main_merged" }, `${run.sprintRunId}:progress:main_merged`); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await runOf(run.sprintRunId)).toMatchObject({ state: "awaiting_approval", awaitingReason: "release_stalled" }); + expect((await currentBatch())!).toMatchObject({ state: "stalled", stalledFrom: "confirmed", mainMerged: true }); + const list = await h.organizer.list(accountContext(), REPO_ID); + expect(list.items).toEqual([expect.objectContaining({ kind: "release_stalled", release: expect.objectContaining({ choices: ["continue"] }) })]); + expect(await codeOf(h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "reject" }))).toBe("release_after_main"); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" })).toEqual({ seq: 1, state: "confirmed" }); + const resumed = await runOf(run.sprintRunId); + expect(instructionOf(resumed.currentJobId!)).toMatchObject({ START: "release", RELEASE_STEP: "continue" }); + expect(await codeOf(h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }))).toBe("not_stalled"); + }); + + it("配布の実行が取り消されても、GitHub で main へマージ済みなら差し戻さない", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + h.github.pulls.set(REPO, [{ head: "release/v10.28.0", base: "main", state: "merged", url: `https://github.com/${REPO}/pull/91` }]); + await h.table.finishRun(batch.releaseRunId, "cancelled", null, at()); + await h.organizer.tick("acc-1", REPO_ID); + expect(await h.table.getBatch("acc-1", REPO_ID, 1)).toMatchObject({ state: "confirmed", mainMerged: true }); + expect(answeredOf(a)).toHaveLength(0); + }); + + it("main へのマージの後に取り消された配布の実行は、止まった配布として残り、続きから流せる。ジョブの無いときの取り消しは 409", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + let run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.progress", { seq: 1, stage: "main_merged" }, `${run.sprintRunId}:progress:main_merged`); + await h.control.cancelSprintRun(accountContext(), run.sprintRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await runOf(run.sprintRunId)).toMatchObject({ state: "awaiting_approval", awaitingReason: "release_stalled", cancelRequested: false }); + expect((await currentBatch())!).toMatchObject({ state: "stalled", stalledFrom: "confirmed", mainMerged: true }); + expect(await codeOf(h.control.cancelSprintRun(accountContext(), run.sprintRunId))).toBe("release_after_main"); + await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "continue" }); + }); + + it("main へのマージの前に取り消された配布の実行は、今のとおり差し戻す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + const run = await runOf(batch.releaseRunId); + await h.control.cancelSprintRun(accountContext(), run.sprintRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect((await runOf(run.sprintRunId)).state).toBe("cancelled"); + expect(await h.table.getBatch("acc-1", REPO_ID, 1)).toMatchObject({ state: "rejected" }); + }); + + it("提示を受けた後に承認ゲート 2 へ届かずに落ちた配布は止まった配布になり、続きは提示のプランを流し直して承認を待つ", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + let run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.presented", { seq: 1, k: 1, sha: "aaaa111", version: "10.28.0", prs: [11], lanes: [], added_prs: [], auto: false }, `${run.sprintRunId}:presented:1`); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect((await currentBatch())!).toMatchObject({ state: "stalled", stalledFrom: "presented" }); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" })).toEqual({ seq: 1, state: "presented" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "present" }); + await event(run.sprintRunId, run.currentJobId, "gate.reached", { kind: "gate2", pr_url: null, material: "資料" }); + await finish(run.sprintRunId, run.currentJobId!, "gate"); + const r = await answer(batch.releaseRunId, "approved"); + expect(instructionOf(r.jobId!)).toMatchObject({ RELEASE_STEP: "confirm:1" }); + }); + + it("提示を受けた後に落ちた配布は差し戻せる", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + await event(run.sprintRunId, run.currentJobId, "release.presented", { seq: 1, k: 1, sha: "aaaa111", version: "10.28.0", prs: [11], lanes: [], added_prs: [], auto: false }, `${run.sprintRunId}:presented:1`); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "reject" })).toEqual({ seq: 1, state: "rejected" }); + }); + + it("確定の後に止まり本番に手を付けた跡が無ければ confirm:、GitHub に枝があれば continue", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + let run = await runOf(batch.releaseRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "confirm:1" }); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + h.github.heads.set(`${REPO}#release/v10.28.0`, "dddd444"); + await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "continue" }); + run = await runOf(batch.releaseRunId); + expect(instructionOf(run.currentJobId!)).toMatchObject({ RELEASE_STEP: "continue" }); + }); + + it("提示の前に止まった配布の差し戻しは、受けた依頼すべてへ rejected を返す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + const run = await runOf(batch.releaseRunId); + await finish(run.sprintRunId, run.currentJobId!, "failed"); + expect(await h.organizer.resolve(accountContext(), REPO_ID, 1, { choice: "reject" })).toEqual({ seq: 1, state: "rejected" }); + expect(answeredOf(a).map((e) => e.payload)).toEqual([expect.objectContaining({ result: "rejected" })]); + }); + + it("答えを書いた後に届け損ねた依頼は、見回りが 1 回だけ届け直し、届け終えたら索引から外れる", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + // 手動の実行の再開を一度だけ落とす(busy として扱われ、届け終えない) + const original = h.control.resumeForRelease.bind(h.control); + h.control.resumeForRelease = async () => "busy"; + await settle(batch.releaseRunId, [11]); + expect(answeredOf(a)).toHaveLength(1); + expect((await h.table.listOrgRequests("acc-1", REPO_ID))[0]!.deliveredAt).toBeNull(); + expect(await h.table.listOrganizerActive()).toEqual([{ accountId: "acc-1", repositoryId: REPO_ID }]); + h.control.resumeForRelease = original; + await h.control.sweep(); + await h.control.sweep(); + expect(answeredOf(a)).toHaveLength(1); + expect((await h.table.listOrgRequests("acc-1", REPO_ID))[0]!.deliveredAt).not.toBeNull(); + expect(await h.table.listOrganizerActive()).toEqual([]); + expect((await runOf(a)).state).toBe("starting"); + }); + + it("再開した実行が別の承認ゲートで待っていれば、届け直しは再開せずに届け終える", async () => { + const a = await startRun(1); + const id = await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + await settle(batch.releaseRunId, [11]); + // 再開した手動の実行が別の承認ゲートで止まる + const run = await runOf(a); + await finish(a, run.currentJobId!, "gate"); + expect(await runOf(a)).toMatchObject({ state: "awaiting_approval", awaitingReason: null }); + expect(await h.control.resumeForRelease(a, id)).toBe("not_waiting"); + expect((await runOf(a)).currentJobId).toBeNull(); + }); + + it("同じ依頼をランナーが再送しても受けた依頼の行は 1 行(I59)", async () => { + const a = await startRun(1); + const id = await requestRelease(a, [11]); + const rows = await h.organizer.newRequestRows(await runOf(a), { requested: [{ request_id: id, lane: `run-${a}`, prs: [11], issues: [1], ndf_version: "10.27.16", requested_at: at() }], presented: [], progress: [], settled: null }); + expect(rows).toEqual([]); + expect(await h.table.listOrgRequests("acc-1", REPO_ID)).toHaveLength(1); + }); +}); + +describe("一覧と履歴(受け入れ条件 9・11、I66)", () => { + it("一覧は同じリポジトリの承認の依頼とループの人の判断待ちを 1 つの並びで返し、別のリポジトリのものを含まない", async () => { + h.github.installations.get(9)!.set(202, "org-a/repo-b"); + h.github.openIssues.set("org-a/repo-b", [{ number: 1, title: "x" }]); + await h.table.putRegistration({ accountId: "acc-1", repositoryId: 202, fullName: "org-a/repo-b", installationId: 9, registeredBy: "user-1", registeredAt: "2026-11-01T00:00:00Z" }); + const a = await startRun(1); + const other = (await h.control.startSprintRun(accountContext(), { repositoryId: 202, issues: [1] })).sprint_run_id; + for (const id of [a, other]) { + const run = await runOf(id); + await event(id, run.currentJobId, "gate.reached", { kind: "gate1", pr_url: `https://github.com/${REPO}/pull/5`, material: "m" }); + await finish(id, run.currentJobId!, "gate"); + } + const list = await h.organizer.list(accountContext(), REPO_ID); + expect(list.items).toEqual([expect.objectContaining({ kind: "gate1", sprint_run_id: a, pr_url: `https://github.com/${REPO}/pull/5` })]); + }); + + it("履歴は提示・確定・結果と記録を新しい順に返す", async () => { + const a = await startRun(1); + await requestRelease(a, [11]); + const batch = (await currentBatch())!; + await present(1, "aaaa111", [11]); + await answer(batch.releaseRunId, "approved"); + await settle(batch.releaseRunId, [11]); + const history = await h.organizer.history(accountContext(), REPO_ID, 20); + expect(history.items).toHaveLength(1); + expect(history.items[0]).toMatchObject({ seq: 1, state: "released", confirmed_k: 1, result: { version: "10.28.0" } }); + expect(history.items[0]!.log.map((l) => l.kind)).toEqual(["composed", "presented", "confirmed", "released"]); + }); + + it("別のアカウントのリポジトリは 404", async () => { + expect(await codeOf(h.organizer.list(accountContext({ accountId: "acc-2" }), REPO_ID))).toBe("repository_not_found"); + }); +}); + +describe("laneOf", () => { + it("ループはループの名前、手動の実行は実行の識別子を切り詰めてレーンの名前にする", () => { + expect(laneOf({ sprintRunId: "x" }, "LFactory3-very-long-loop-name-over-limit")).toBe("loop-lfactory3-very-long-loop-na"); + expect(laneOf({ sprintRunId: "0123456789ABCDEF0123456789abcdef" }, null)).toBe("run-0123456789abcdef"); + expect(laneOf({ sprintRunId: "sr1" }, null)).toBe("run-sr1"); + }); +}); diff --git a/factory/api/src/organizer/control.ts b/factory/api/src/organizer/control.ts new file mode 100644 index 000000000..82e1ded70 --- /dev/null +++ b/factory/api/src/organizer/control.ts @@ -0,0 +1,689 @@ +// 配布のまとめ(OrganizerControl。#2439 の F2〜F8)。同じリポジトリで並行する実行の配布の依頼を受け、まとめた配布を 1 つずつ組み、 +// 配布の実行を起こし、承認の答えで出し直すか確定し、結果を範囲の実行へ返す。常駐せず、実行の制御の停止の後の処理・再開の要求・ +// 見回りと、画面の経路の中で動く(非機能の「可用性」)。状態はすべて実行の表の行で、tick は根の状態だけで次の 1 手が決まる +import type { ReleaseAnsweredEvent } from "@ndf-factory/contracts"; +import type { AccountContext } from "../accounts/model.ts"; +import { ConflictError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts"; +import type { GitHubPort } from "../github/port.ts"; +import { releaseAnsweredKey } from "../progress/authorize.ts"; +import type { ProgressReader, ReleaseEvents } from "../progress/read.ts"; +import type { RunControl } from "../runs/control.ts"; +import { isTerminalRun, type Registration, type ReleaseStep, type SprintRun } from "../runs/model.ts"; +import type { RunsTable } from "../runs/table.ts"; +import { + choicesOf, + emptyRoot, + isTerminalBatch, + laneOf, + newestVersion, + presentedVersion, + requestsToAnswer, + resumeStepOf, + stalledFromOf, + stepFrom, + type BatchLane, + type OrganizerRoot, + type OrgLog, + type OrgLogKind, + type OrgRequest, + type ReleaseAnswer, + type ReleaseBatch, +} from "./model.ts"; + +// 根の状態を変える書き込みが落ちたときに読み直す回数(I67) +const ROOT_RETRIES = 3; +// 起動の記録が無いまま pending の配布の実行のジョブを起こし直すまでの時間(組んだ直後の起動と取り合わない) +const RELAUNCH_AFTER_MS = 60_000; + +export interface OrganizerDeps { + table: RunsTable; + github: GitHubPort; + reader: ProgressReader; + control: RunControl; + appendAnswered: (event: ReleaseAnsweredEvent) => Promise<{ written: boolean }>; + now?: () => Date; + log?: (entry: Record) => void; +} + +// 自分で開いた本番の枝を探す、差し戻したまとめた配布の件数(新しい方から) +const OWN_RELEASE_LOOKBACK = 20; + +const isExternalRelease = (head: string) => head.startsWith("release/v") || head === "develop"; + +export class OrganizerControl { + private readonly now: () => Date; + private readonly log: (entry: Record) => void; + + constructor(private readonly deps: OrganizerDeps) { + this.now = deps.now ?? (() => new Date()); + this.log = deps.log ?? ((entry) => console.log(JSON.stringify(entry))); + } + + private iso(): string { + return this.now().toISOString(); + } + + private logRow(root: Pick, kind: OrgLogKind, seq: number | null, detail: Record): OrgLog { + return { accountId: root.accountId, repositoryId: root.repositoryId, at: this.iso(), kind, seq, detail }; + } + + // ---- 受ける(停止の後の処理が呼ぶ) ---- + + // ジョブが送った release.requested から、まだ無い受けた依頼の行を作る(I59)。実行の移りと同じトランザクションで書く + async newRequestRows(run: SprintRun, events: ReleaseEvents): Promise { + if (events.requested.length === 0) return []; + const existing = new Set((await this.deps.table.listOrgRequests(run.accountId, run.repositoryId)).map((r) => r.requestId)); + const out: OrgRequest[] = []; + for (const p of events.requested) { + if (existing.has(p.request_id) || out.some((r) => r.requestId === p.request_id)) continue; + out.push({ + accountId: run.accountId, + repositoryId: run.repositoryId, + requestId: p.request_id, + sprintRunId: run.sprintRunId, + loopId: run.loopId ?? null, + lane: p.lane, + prs: p.prs, + issues: p.issues, + ndfVersion: p.ndf_version, + requestedAt: p.requested_at, + answer: null, + deliveredAt: null, + }); + } + return out; + } + + // ---- tick: 根の続き(届け直し・流れている配布の続き・待ちの判定と組む) ---- + + async tick(accountId: string, repositoryId: number): Promise { + for (let attempt = 0; attempt < ROOT_RETRIES; attempt++) { + await this.deliverAll(accountId, repositoryId); + const root = (await this.deps.table.getOrganizer(accountId, repositoryId)) ?? null; + const state = root ?? emptyRoot(accountId, repositoryId, this.iso()); + if (state.currentSeq !== null) { + const batch = await this.deps.table.getBatch(accountId, repositoryId, state.currentSeq); + if (!batch) return; + const moved = await this.advance(state, batch); + // 流れている配布が終わったら、残った依頼で次のまとめた配布を組む(同じ tick で続ける) + if (moved === "ended") continue; + if (moved === "retry") continue; + return; + } + const composed = await this.compose(root, state); + if (composed !== "retry") return; + } + } + + // 流れているまとめた配布を、配布の実行の状態と事象に合わせて 1 手進める + private async advance(root: OrganizerRoot, batch: ReleaseBatch): Promise<"ended" | "retry" | "same"> { + const { table, reader } = this.deps; + const run = await table.getRun(batch.releaseRunId); + if (!run) return "same"; + const events = await reader.findReleaseEvents(run.sprintRunId); + let next: ReleaseBatch = structuredClone(batch); + const logs: OrgLog[] = []; + + // 提示(E3・E12)。提示は上書きせず足す。3 つ目は組まない(I60) + for (const p of events.presented) { + if (p.seq !== batch.seq || next.presentations.some((x) => x.k === p.k) || p.k !== next.presentations.length + 1) continue; + next.presentations.push({ k: p.k as 1 | 2, sha: p.sha, version: p.version, prs: p.prs, lanes: p.lanes, addedPrs: p.added_prs, at: this.iso() }); + if (p.auto) { + next.state = "confirmed"; + next.confirmedK = p.k as 1 | 2; + logs.push(this.logRow(root, "confirmed", batch.seq, { k: p.k, sha: p.sha, auto: true })); + } else if (next.state === "composed" || next.state === "represented") { + next.state = "presented"; + logs.push(this.logRow(root, "presented", batch.seq, { k: p.k, sha: p.sha, prs: p.prs, added_prs: p.added_prs })); + } + } + if (!next.mainMerged && events.progress.some((p) => p.seq === batch.seq && p.stage === "main_merged")) next.mainMerged = true; + + // 結果(E7) + const settled = events.settled && events.settled.seq === batch.seq ? events.settled : null; + if (settled?.result === "released" && !isTerminalBatch(next.state)) { + next.state = "released"; + next.mainMerged = true; + next.result = { version: settled.version ?? null, prodPrUrl: settled.prod_pr_url ?? null, sha: settled.sha ?? null, prs: settled.prs }; + next.stalledFrom = null; + next.stallReason = null; + const ok = await this.end(root, batch, next, "released", settled.prs, this.logRow(root, "released", batch.seq, { ...next.result })); + return ok ? "ended" : "retry"; + } + + // 配布の実行が止まった(I65・決定 11) + if (run.state === "awaiting_approval" && run.awaitingReason === "release_stalled" && next.state !== "stalled") { + const from = stalledFromOf(next.state); + if (from) { + next.mainMerged = next.mainMerged || (await this.mergedOnGitHub(run, next)); + next.stalledFrom = from; + next.stallReason = settled?.reason ?? run.failureReason ?? "job_failed"; + next.state = "stalled"; + logs.push(this.logRow(root, "stalled", batch.seq, { from, reason: next.stallReason, main_merged: next.mainMerged })); + } + } + + // 配布の実行が取り消しなどで終わった。main へのマージの前なら差し戻す(I63)。ランナーが段を送る前に取り消されても + // 公開済みの配布を差し戻さないよう、GitHub の main へのマージを照らしてから決める + if (isTerminalRun(run.state) && !isTerminalBatch(next.state) && !next.mainMerged) next.mainMerged = await this.mergedOnGitHub(run, next); + if (isTerminalRun(run.state) && !isTerminalBatch(next.state) && !next.mainMerged) { + next.state = "rejected"; + const ok = await this.end(root, batch, next, "rejected", [], this.logRow(root, "rejected", batch.seq, { reason: `release_run_${run.state}` })); + return ok ? "ended" : "retry"; + } + + if (JSON.stringify(next) !== JSON.stringify(batch)) { + next.updatedAt = this.iso(); + const ok = await table.writeOrganizer({ + root: { ...root, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: [batch.state], + ...(logs[0] ? { log: logs[0] } : {}), + }); + if (!ok) return "retry"; + for (const extra of logs.slice(1)) await table.appendOrgLog(extra); + } + + // 組んだ後に起動の前で止まった配布の実行を起こし直す(ジョブの冪等は startedBy) + if (run.state === "starting" && run.currentJobId) { + const job = await table.getJob(run.currentJobId); + if ( + job && + job.state === "pending" && + job.runnerKind === "ecs" && + !job.taskArn && + !job.tokenSecretArn && + this.now().getTime() - Date.parse(job.createdAt) > RELAUNCH_AFTER_MS + ) { + await this.deps.control.launchJob(run, job); + } + } + return "same"; + } + + // 本番のプランに手を付けた跡(届いた段の事象か、確定した提示の release/v<版> の枝・main 宛ての Pull Request)。 + // 差し戻したまとめた配布が残した同じ版の枝は跡に数えない(出していないので次の配布は同じ版を提示する) + private async releaseStarted(run: SprintRun, batch: ReleaseBatch, reg: Registration, checkGitHub = true): Promise { + const { reader, github } = this.deps; + if ((await reader.findReleaseEvents(run.sprintRunId)).progress.length > 0) return true; + const version = checkGitHub ? presentedVersion(batch) : null; + if (!version) return false; + const branch = `release/v${version}`; + if ((await this.rejectedReleaseHeads(batch)).has(branch)) return false; + return ( + (await github.branchHead(reg.installationId, reg.repositoryId, reg.fullName, branch)) !== null || + (await github.findPullByHead(reg.installationId, reg.repositoryId, reg.fullName, branch, "main")) !== null + ); + } + + // 配布の実行のまとめた配布が main へマージ済みか(記録・届いた段・GitHub の順に照らす)。終わったまとめた配布は偽 + async releaseMerged(run: SprintRun): Promise { + if (!run.releaseSeq) return false; + const batch = await this.deps.table.getBatch(run.accountId, run.repositoryId, run.releaseSeq); + if (!batch || isTerminalBatch(batch.state)) return false; + if (batch.mainMerged) return true; + const { progress } = await this.deps.reader.findReleaseEvents(run.sprintRunId); + if (progress.some((p) => p.seq === batch.seq && p.stage === "main_merged")) return true; + return this.mergedOnGitHub(run, batch); + } + + // main へのマージを GitHub で照らす(ジョブが段を書く前に落ちても分かるように。決定 11) + private async mergedOnGitHub(run: SprintRun, batch: ReleaseBatch): Promise { + const version = presentedVersion(batch); + if (!version) return false; + const reg = await this.deps.table.getRegistration(run.accountId, run.repositoryId); + if (!reg) return false; + const pull = await this.deps.github.findPullByHead(reg.installationId, reg.repositoryId, reg.fullName, `release/v${version}`, "main"); + return pull?.state === "merged"; + } + + // まとめた配布を終わりの状態にして根を空け、範囲の依頼へ答えを書く(I58・I61) + private async end(root: OrganizerRoot, before: ReleaseBatch, next: ReleaseBatch, end: "released" | "rejected", settledPrs: number[], log: OrgLog): Promise { + const { table } = this.deps; + next.updatedAt = this.iso(); + const ok = await table.writeOrganizer({ + root: { ...root, currentSeq: null, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: [before.state], + log, + }); + if (!ok) return false; + await this.answerRange(next, end, settledPrs); + if (end === "rejected") { + const run = await table.getRun(next.releaseRunId); + if (run && !isTerminalRun(run.state)) await table.finishRun(run.sprintRunId, "stopped", null, this.iso()); + } + return true; + } + + // 範囲の依頼へ答えを書く。終わった後に止まっても、見回りが同じ規則で書き直す(2 回目は I58 で書かれない) + // before を渡すと、その時刻までに出した依頼だけを照らす(終わった後に出し直した依頼を古い配布で答えない) + // known を渡すと読み直さずにそれを照らし、書けた答えをその行へ写す。別の書き手と取り合ったら false(呼び手が読み直す) + private async answerRange(batch: ReleaseBatch, end: "released" | "rejected", settledPrs: number[], before?: string, known?: OrgRequest[]): Promise { + const requests = (known ?? (await this.deps.table.listOrgRequests(batch.accountId, batch.repositoryId))).filter( + (r) => before === undefined || Date.parse(r.requestedAt) <= Date.parse(before), + ); + const targets = requestsToAnswer(batch, requests, end, settledPrs); + let fresh = true; + for (const r of targets) { + const answer: ReleaseAnswer = { + seq: batch.seq, + result: end, + version: end === "released" ? (batch.result?.version ?? null) : null, + prodPrUrl: end === "released" ? (batch.result?.prodPrUrl ?? null) : null, + reason: end === "rejected" ? "release_rejected" : null, + at: this.iso(), + }; + if (await this.deps.table.answerRequest(r.accountId, r.repositoryId, r.requestId, answer)) r.answer = answer; + else fresh = false; + } + return fresh; + } + + // 答えがあって届け終えていない依頼を届ける(E8)。release.answered を追記し、手動の実行は再開する。 + // 実行がまだ別のジョブで動いている(busy)ときは届け終えたことにせず、次の tick で届け直す + private async deliverAll(accountId: string, repositoryId: number): Promise { + const { table, control } = this.deps; + // 終わった配布の答えを書き終える前に止まっていたら、ここで書き直す。依頼の一覧は 1 回だけ読み、書いた答えはその行へ写す + let requests = await table.listOrgRequests(accountId, repositoryId); + let fresh = true; + for (const batch of await table.listBatches(accountId, repositoryId, 5)) { + if (batch.state === "released" || batch.state === "rejected") { + if (!requests.some((r) => r.answer === null)) break; + if (!(await this.answerRange(batch, batch.state, batch.result?.prs ?? [], batch.updatedAt, requests))) fresh = false; + } + } + if (!fresh) requests = await table.listOrgRequests(accountId, repositoryId); + for (const r of requests) { + if (!r.answer || r.deliveredAt !== null) continue; + const event: ReleaseAnsweredEvent = { + sprint_run_id: r.sprintRunId, + idempotency_key: releaseAnsweredKey(r.answer.seq, r.sprintRunId), + type: "release.answered", + at: r.answer.at, + source: "control", + job_id: null, + schema_version: 1, + payload: { + seq: r.answer.seq, + request_id: r.requestId, + result: r.answer.result, + ...(r.answer.version ? { version: r.answer.version } : {}), + ...(r.answer.prodPrUrl ? { prod_pr_url: r.answer.prodPrUrl } : {}), + }, + }; + await this.deps.appendAnswered(event); + if (r.loopId === null) { + const resumed = await control.resumeForRelease(r.sprintRunId, r.requestId); + if (resumed === "busy") continue; + } + await table.markDelivered(accountId, repositoryId, r.requestId, this.iso()); + } + } + + // 流れている配布が無いとき: 未回答の依頼があれば、待ちの理由を照らしてから組む(F2・I62) + private async compose(stored: OrganizerRoot | null, root: OrganizerRoot): Promise<"done" | "retry"> { + const { table, github, control } = this.deps; + const requests = await table.listOrgRequests(root.accountId, root.repositoryId); + const pending = requests.filter((r) => r.answer === null); + if (pending.length === 0) { + if (stored && stored.waitReason !== null) { + await table.writeOrganizer({ root: { ...stored, waitReason: null, waitDetail: null, version: stored.version + 1, updatedAt: this.iso() }, expectVersion: stored.version }); + } + return "done"; + } + const reg = await table.getRegistration(root.accountId, root.repositoryId); + if (!reg) return "done"; + + // Factory の外の本番の Pull Request(I62・決定 8)。差し戻したまとめた配布が自分で開いた release/v<版> は外のものと見なさない + // (本番の Pull Request を開いた後に止まって差し戻すと開いたまま残り、数えると人が閉じるまで次の配布が待ち続ける) + const candidates = (await github.listOpenPullHeads(reg.installationId, reg.repositoryId, reg.fullName, "main")).filter((p) => isExternalRelease(p.head)); + const own = candidates.length > 0 ? await this.rejectedReleaseHeads(root) : new Set(); + const external = candidates.find((p) => !own.has(p.head)); + if (external) return this.wait(stored, root, "external_release_open", external.url); + + // 依頼を出していない古い版の実行(依頼を出して待つ実行は自分で配布しないため数えない) + const slots = await table.getSlots(root.accountId, root.repositoryId); + let requestedLegacy = 0; + const runs = new Map(); + for (const id of new Set(pending.map((r) => r.sprintRunId))) { + const run = await table.getRun(id); + if (!run) continue; + runs.set(id, run); + if (run.legacy === true && run.state === "awaiting_approval" && run.awaitingReason === "release_requested") requestedLegacy += 1; + } + const legacyOpen = (slots.legacy ?? 0) - requestedLegacy; + if (legacyOpen > 0) return this.wait(stored, root, "legacy_run", String(legacyOpen)); + + // レーン: 依頼のあるレーンと、同じリポジトリで動いている依頼の無いレーン + const lanes = new Map(); + for (const r of pending) { + const lane = lanes.get(r.lane) ?? { lane: r.lane, sprintRunId: r.sprintRunId, loopId: r.loopId, requestIds: [], issues: [] }; + lane.requestIds.push(r.requestId); + lane.issues = [...new Set([...lane.issues, ...r.issues])]; + lanes.set(r.lane, lane); + } + for (const run of await table.listRuns(root.accountId, 1000)) { + if (run.repositoryId !== root.repositoryId || run.purpose === "release" || isTerminalRun(run.state)) continue; + const loopName = run.loopId ? ((await table.getLoop(run.accountId, run.loopId))?.name ?? null) : null; + const name = laneOf(run, loopName); + if (!lanes.has(name)) lanes.set(name, { lane: name, sprintRunId: run.sprintRunId, loopId: run.loopId ?? null, requestIds: [], issues: run.issues }); + } + + const base = runs.get(pending[0]!.sprintRunId) ?? [...runs.values()][0]; + if (!base) return "done"; + const seq = root.nextSeq; + const ndfVersion = newestVersion(pending.map((r) => r.ndfVersion), base.ndfVersion); + const { run, job } = control.buildReleaseRun(base, seq, ndfVersion); + const now = this.iso(); + const batch: ReleaseBatch = { + accountId: root.accountId, + repositoryId: root.repositoryId, + seq, + state: "composed", + releaseRunId: run.sprintRunId, + lanes: [...lanes.values()], + presentations: [], + confirmedK: null, + mainMerged: false, + result: null, + stallReason: null, + stalledFrom: null, + createdAt: now, + updatedAt: now, + }; + const ok = await table.composeRelease({ + write: { + root: { ...root, currentSeq: seq, nextSeq: seq + 1, version: root.version + 1, waitReason: null, waitDetail: null, updatedAt: now }, + expectVersion: stored ? stored.version : null, + batch, + log: this.logRow(root, "composed", seq, { release_run_id: run.sprintRunId, lanes: batch.lanes.map((l) => ({ lane: l.lane, requested: l.requestIds.length > 0 })) }), + }, + run, + job, + legacySeq: slots.legacySeq ?? null, + }); + if (!ok) return "retry"; + await control.launchJob(run, job); + return "done"; + } + + // 差し戻したまとめた配布の本番の枝(release/v<版>)。新しい方から OWN_RELEASE_LOOKBACK 件を見る + private async rejectedReleaseHeads(root: Pick): Promise> { + const batches = await this.deps.table.listBatches(root.accountId, root.repositoryId, OWN_RELEASE_LOOKBACK); + const versions = batches.filter((b) => b.state === "rejected").map((b) => presentedVersion(b)); + return new Set(versions.filter((v): v is string => v !== null).map((v) => `release/v${v}`)); + } + + private async wait(stored: OrganizerRoot | null, root: OrganizerRoot, reason: "external_release_open" | "legacy_run", detail: string): Promise<"done" | "retry"> { + if (stored && stored.waitReason === reason && stored.waitDetail === detail) return "done"; + const ok = await this.deps.table.writeOrganizer({ + root: { ...root, waitReason: reason, waitDetail: detail, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: stored ? stored.version : null, + log: this.logRow(root, "waited", null, { reason, detail }), + }); + return ok ? "done" : "retry"; + } + + // ---- 答え(再開の要求が配布の実行を回す。F4) ---- + + // 配布の実行への承認の依頼の答えから、再開の手を決める。差し戻しなら null(ジョブを起こさない) + async decide(run: SprintRun, approvalRequestId: string): Promise { + const { table, reader, github } = this.deps; + // 提示の記録が停止の後の処理で書けていなければ先に進める + await this.tick(run.accountId, run.repositoryId); + const answer = await reader.findGateAnswer(run.sprintRunId, approvalRequestId); + if (!answer) throw new ServiceUnavailableError("approval_answer_unavailable"); + for (let attempt = 0; attempt < ROOT_RETRIES; attempt++) { + const root = await table.getOrganizer(run.accountId, run.repositoryId); + const batch = run.releaseSeq ? await table.getBatch(run.accountId, run.repositoryId, run.releaseSeq) : null; + if (!root || !batch) throw new ConflictError("release_not_found", "まとめた配布が見つかりません"); + // 判断(承認ゲートの外の問い)は、止まったプランをまとめた配布の状態から流し直す(止まった配布の「続ける」と同じ規則)。 + // 常に continue だと、提示のプランの中で止まった配布が承認ゲート 2 を通らずに本番のプランへ進む + if (answer.kind !== "gate2") { + const from = stalledFromOf(batch.state); + if (!from) throw new ConflictError("not_presented", "まとめた配布は判断の答えで続けられる状態にありません"); + const reg = from === "confirmed" ? await table.getRegistration(run.accountId, run.repositoryId) : null; + const started = from === "confirmed" && (batch.mainMerged || (reg !== null && (await this.releaseStarted(run, batch, reg)))); + return stepFrom(from, batch, started); + } + // 同じ答えの再送(ジョブを作る前に止まった)は、書いた状態から同じ手を返す。その提示に記録した承認の依頼と一致し、 + // 実行が今も承認ゲート 2 で止まっているときだけにする(本番のプランの中の判断で止まった後の古い答えで流し直さない) + if (batch.state === "represented" || batch.state === "confirmed") { + const k = batch.state === "represented" ? 1 : batch.confirmedK; + const answered = batch.presentations.find((p) => p.k === k); + const gate = await reader.findLastGate(run.sprintRunId); + if (!answered?.approvalRequestId || answered.approvalRequestId !== approvalRequestId || gate?.payload.kind !== "gate2") { + throw new ConflictError("not_presented", "この承認は今待っている提示への答えではありません"); + } + if (batch.state === "represented") return "represent"; + return k === 2 ? "confirm:2" : "confirm:1"; + } + if (batch.state !== "presented") throw new ConflictError("not_presented", "まとめた配布は承認を待っていません"); + // 前の提示へ答えた承認の再送は、今待っている提示の承認にしない + if (batch.presentations.some((p) => p.approvalRequestId === approvalRequestId)) { + throw new ConflictError("not_presented", "この承認は前の提示への答えです。今の提示は承認を待っています"); + } + if (answer.decision === "rejected") { + const next: ReleaseBatch = { ...structuredClone(batch), state: "rejected" }; + if (await this.end(root, batch, next, "rejected", [], this.logRow(root, "rejected", batch.seq, { approval_request_id: approvalRequestId }))) { + await this.tick(run.accountId, run.repositoryId); + return null; + } + continue; + } + const last = batch.presentations.at(-1); + if (!last) throw new ConflictError("not_presented", "まとめた配布は承認を待っていません"); + let step: ReleaseStep; + const next: ReleaseBatch = { ...structuredClone(batch), updatedAt: this.iso() }; + next.presentations.at(-1)!.approvalRequestId = approvalRequestId; + let log: OrgLog; + if (last.k === 1) { + // 提示 1 への承認だけ develop の先頭を比べる(I60・決定 7) + const reg = await table.getRegistration(run.accountId, run.repositoryId); + const head = reg ? await github.branchHead(reg.installationId, reg.repositoryId, reg.fullName, "develop") : null; + if (head === null) throw new ServiceUnavailableError("develop_unreadable"); + if (head !== last.sha) { + next.state = "represented"; + step = "represent"; + log = this.logRow(root, "represented", batch.seq, { from: last.sha, head }); + } else { + next.state = "confirmed"; + next.confirmedK = 1; + step = "confirm:1"; + log = this.logRow(root, "confirmed", batch.seq, { k: 1, sha: last.sha }); + } + } else { + next.state = "confirmed"; + next.confirmedK = 2; + step = "confirm:2"; + log = this.logRow(root, "confirmed", batch.seq, { k: 2, sha: last.sha }); + } + const ok = await table.writeOrganizer({ + root: { ...root, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: ["presented"], + log, + }); + if (ok) return step; + } + throw new ServiceUnavailableError("organizer_busy"); + } + + // ---- 画面の経路(F6・F7) ---- + + private async registration(ctx: AccountContext, repositoryId: number): Promise { + const reg = await this.deps.table.getRegistration(ctx.accountId, repositoryId); + if (!reg) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); + return reg; + } + + private releaseSummary(batch: ReleaseBatch, requests: OrgRequest[], choices: ("continue" | "reject")[]) { + const last = batch.presentations.at(-1) ?? null; + return { + seq: batch.seq, + k: last?.k ?? null, + sha: last?.sha ?? null, + lanes: batch.lanes.map((l) => ({ + lane: l.lane, + requested: l.requestIds.length > 0, + prs: [...new Set(requests.filter((r) => l.requestIds.includes(r.requestId)).flatMap((r) => r.prs))], + })), + added_prs: last?.addedPrs ?? [], + choices, + }; + } + + // リポジトリの承認と問いの一覧(AC 9)。読むだけで、承認の依頼へ答えを書く経路を持たない(I66) + async list(ctx: AccountContext, repositoryId: number) { + const { table, reader } = this.deps; + await this.registration(ctx, repositoryId); + const root = await table.getOrganizer(ctx.accountId, repositoryId); + const requests = await table.listOrgRequests(ctx.accountId, repositoryId); + const items: Record[] = []; + for (const run of await table.listRuns(ctx.accountId, 1000)) { + if (run.repositoryId !== repositoryId || run.state !== "awaiting_approval") continue; + if (run.awaitingReason === "release_requested") continue; + const batch = run.purpose === "release" && run.releaseSeq ? await table.getBatch(ctx.accountId, repositoryId, run.releaseSeq) : null; + if (run.awaitingReason === "release_stalled") { + if (!batch) continue; + items.push({ kind: "release_stalled", sprint_run_id: run.sprintRunId, created_at: run.updatedAt, release: this.releaseSummary(batch, requests, choicesOf(batch)) }); + continue; + } + const gate = await reader.findLastGate(run.sprintRunId); + if (!gate) continue; + items.push({ + kind: gate.payload.kind, + sprint_run_id: run.sprintRunId, + ...(run.loopId ? { loop_id: run.loopId } : {}), + pr_url: gate.payload.pr_url, + created_at: gate.at, + ...(batch ? { release: this.releaseSummary(batch, requests, gate.payload.choices ?? []) } : {}), + }); + } + for (const loop of await table.listLoops(ctx.accountId)) { + if (loop.repositoryId !== repositoryId) continue; + for (const p of loop.pending) items.push({ kind: "loop_pending", loop_id: loop.loopId, number: p.number, reason: p.reason, created_at: loop.updatedAt }); + } + items.sort((a, b) => ((a.created_at as string) < (b.created_at as string) ? -1 : (a.created_at as string) > (b.created_at as string) ? 1 : 0)); + return { + organizer: { current_seq: root?.currentSeq ?? null, wait_reason: root?.waitReason ?? null, wait_detail: root?.waitDetail ?? null }, + items, + }; + } + + // まとめた配布の履歴(新しい順) + async history(ctx: AccountContext, repositoryId: number, limit: number) { + const { table } = this.deps; + await this.registration(ctx, repositoryId); + const logs = await table.listOrgLogs(ctx.accountId, repositoryId); + const batches = await table.listBatches(ctx.accountId, repositoryId, limit); + return { + items: batches.map((b) => ({ + seq: b.seq, + state: b.state, + release_run_id: b.releaseRunId, + presentations: b.presentations.map((p) => ({ k: p.k, sha: p.sha, version: p.version, prs: p.prs, lanes: p.lanes, added_prs: p.addedPrs, at: p.at })), + confirmed_k: b.confirmedK, + result: b.result ? { version: b.result.version, prod_pr_url: b.result.prodPrUrl, sha: b.result.sha, prs: b.result.prs } : null, + log: logs.filter((l) => l.seq === b.seq).map((l) => ({ at: l.at, kind: l.kind, detail: l.detail })), + })), + }; + } + + // 止まった配布を解く(F7)。main へのマージの後の差し戻しは断る(I63) + async resolve(ctx: AccountContext, repositoryId: number, seq: number, input: { choice?: unknown }) { + const { table, reader, github, control } = this.deps; + const choice = input.choice; + if (choice !== "continue" && choice !== "reject") throw new InvalidRequestError("choice は continue か reject です"); + const reg = await this.registration(ctx, repositoryId); + // 止まった印が停止の後の処理で書けていなければ先に進める + await this.tick(ctx.accountId, repositoryId); + for (let attempt = 0; attempt < ROOT_RETRIES; attempt++) { + const root = await table.getOrganizer(ctx.accountId, repositoryId); + const batch = await table.getBatch(ctx.accountId, repositoryId, seq); + if (!root || !batch) throw new NotFoundError("release_not_found", "まとめた配布が見つかりません"); + if (batch.state !== "stalled" || !batch.stalledFrom) throw new ConflictError("not_stalled", "まとめた配布は止まっていません"); + if (choice === "reject") { + if (batch.mainMerged) throw new ConflictError("release_after_main", "main へマージした配布は差し戻せません"); + const next: ReleaseBatch = { ...structuredClone(batch), state: "rejected" }; + if (await this.end(root, batch, next, "rejected", [], this.logRow(root, "resolved", seq, { choice }))) { + await this.tick(ctx.accountId, repositoryId); + return { seq, state: "rejected" as const }; + } + continue; + } + const run = await table.getRun(batch.releaseRunId); + if (!run) throw new NotFoundError("release_not_found", "まとめた配布が見つかりません"); + const started = batch.mainMerged || (await this.releaseStarted(run, batch, reg, batch.stalledFrom === "confirmed")); + const step = resumeStepOf(batch, started); + const next: ReleaseBatch = { ...structuredClone(batch), state: batch.stalledFrom, stalledFrom: null, stallReason: null, updatedAt: this.iso() }; + // 状態の戻しと再開のジョブの作成を同じトランザクションで書く(間に見回りが止まった印を書き戻さない) + const ok = await control.resumeRelease(run, step, { + root: { ...root, version: root.version + 1, updatedAt: this.iso() }, + expectVersion: root.version, + batch: next, + expectBatchState: ["stalled"], + log: this.logRow(root, "resolved", seq, { choice, step }), + }); + if (ok) return { seq, state: next.state }; + const now = await table.getRun(batch.releaseRunId); + if (!now || now.state !== "awaiting_approval" || now.awaitingReason !== "release_stalled" || now.currentJobId !== null) { + throw new ConflictError("not_stalled", "配布の実行は止まっていません"); + } + } + throw new ServiceUnavailableError("organizer_busy"); + } + + // ---- ランナー向け ---- + + // そのジョブの実行のレーンの依頼の答えと、リポジトリの配布済みの範囲(#2436 の答えと範囲の形) + async answersFor(run: SprintRun) { + const { table } = this.deps; + const requests = (await table.listOrgRequests(run.accountId, run.repositoryId)).filter((r) => + run.loopId ? r.loopId === run.loopId : r.sprintRunId === run.sprintRunId, + ); + const batches = await table.listBatches(run.accountId, run.repositoryId, 100); + return { + answers: requests + .filter((r) => r.answer !== null) + .map((r) => ({ request_id: r.requestId, result: r.answer!.result, version: r.answer!.version, release: r.answer!.prodPrUrl, reason: r.answer!.reason, at: r.answer!.at })), + ranges: batches + .filter((b) => b.state === "released" && b.result) + .map((b) => ({ seq: b.seq, sha: b.result!.sha, version: b.result!.version, prs: b.result!.prs, lanes: b.lanes.map((l) => l.lane) })), + }; + } + + // 配布の実行のまとめた配布の中身(batch.json) + async batchFor(run: SprintRun, step: ReleaseStep | null) { + const { table } = this.deps; + if (run.purpose !== "release" || !run.releaseSeq) return null; + const batch = await table.getBatch(run.accountId, run.repositoryId, run.releaseSeq); + if (!batch) return null; + const requests = await table.listOrgRequests(run.accountId, run.repositoryId); + return { + seq: batch.seq, + step: step ?? "present", + lanes: batch.lanes.map((l) => ({ + lane: l.lane, + requests: l.requestIds.map((id) => ({ id, prs: requests.find((r) => r.requestId === id)?.prs ?? [] })), + issues: l.issues, + })), + presentations: batch.presentations.map((p) => ({ k: p.k, sha: p.sha, version: p.version, prs: p.prs, lanes: p.lanes, added_prs: p.addedPrs, at: p.at })), + choices: choicesOf(batch), + }; + } + + // ---- 見回り(F8) ---- + + async sweep(): Promise { + for (const { accountId, repositoryId } of await this.deps.table.listOrganizerActive()) { + await this.tick(accountId, repositoryId).catch((err: Error) => + this.log({ event: "organizer_tick_failed", account_id: accountId, repository_id: repositoryId, error: err.name }), + ); + } + } +} diff --git a/factory/api/src/organizer/model.ts b/factory/api/src/organizer/model.ts new file mode 100644 index 000000000..5182990de --- /dev/null +++ b/factory/api/src/organizer/model.ts @@ -0,0 +1,209 @@ +// 集約「オーガナイザーの進行」(#2439 の設計の「データ構造」)。根・受けた依頼・まとめた配布・記録の行の型と、 +// 状態の移りを決める純粋な関数。表への読み書きは実行の表(runs/table.ts)が持つ +import { organizerLaneName } from "@ndf-factory/contracts"; +import type { ReleaseStep } from "../runs/model.ts"; + +export type OrganizerWaitReason = "external_release_open" | "legacy_run"; + +// 根(1 リポジトリに 1 行)。version は楽観的な排他の版(I67) +export interface OrganizerRoot { + accountId: string; + repositoryId: number; + version: number; + // 流れているまとめた配布の連番。空は「流れていない」(I56) + currentSeq: number | null; + nextSeq: number; + waitReason: OrganizerWaitReason | null; + waitDetail: string | null; + updatedAt: string; +} + +export interface ReleaseAnswer { + seq: number; + result: "released" | "rejected"; + version: string | null; + prodPrUrl: string | null; + reason: string | null; + at: string; +} + +// 受けた依頼(依頼 1 件に 1 行。I59)。答えは 1 回だけ書く(I58) +export interface OrgRequest { + accountId: string; + repositoryId: number; + requestId: string; + sprintRunId: string; + loopId: string | null; + lane: string; + prs: number[]; + issues: number[]; + ndfVersion: string; + requestedAt: string; + answer: ReleaseAnswer | null; + // 答えを依頼元へ届け終えた時刻。空の間は見回りの索引に残る + deliveredAt: string | null; +} + +export const BATCH_STATES = ["composed", "presented", "represented", "confirmed", "stalled", "released", "rejected"] as const; +export type BatchState = (typeof BATCH_STATES)[number]; +export const isTerminalBatch = (state: BatchState) => state === "released" || state === "rejected"; +export type StalledFrom = "composed" | "presented" | "represented" | "confirmed"; + +export interface BatchLane { + lane: string; + sprintRunId: string; + loopId: string | null; + requestIds: string[]; + issues: number[]; +} + +export interface Presentation { + k: 1 | 2; + sha: string; + version: string; + prs: number[]; + lanes: { lane: string; requested: boolean }[]; + addedPrs: number[]; + at: string; + // この提示へ答えた承認の依頼(承認ゲート 2)。古い承認の再送を次の提示の承認と取り違えないために残す。前の記録には無い + approvalRequestId?: string | null; +} + +export interface BatchResult { + version: string | null; + prodPrUrl: string | null; + sha: string | null; + prs: number[]; +} + +// まとめた配布(I56・I57) +export interface ReleaseBatch { + accountId: string; + repositoryId: number; + seq: number; + state: BatchState; + releaseRunId: string; + lanes: BatchLane[]; + // 提示は上書きせず足す。2 つまで(I60) + presentations: Presentation[]; + confirmedK: 1 | 2 | null; + mainMerged: boolean; + result: BatchResult | null; + stallReason: string | null; + stalledFrom: StalledFrom | null; + createdAt: string; + updatedAt: string; +} + +export const LOG_KINDS = ["waited", "composed", "presented", "represented", "confirmed", "stalled", "resolved", "released", "rejected"] as const; +export type OrgLogKind = (typeof LOG_KINDS)[number]; + +// 記録の行(追記だけ) +export interface OrgLog { + accountId: string; + repositoryId: number; + at: string; + kind: OrgLogKind; + seq: number | null; + detail: Record; +} + +// 根の状態を変える書き込み(I67)。expectVersion が null なら根がまだ無いことを条件にする。 +// batch を渡すと同じトランザクションで書き、expectBatchState があればその状態のときだけ書く +export interface OrganizerWrite { + root: OrganizerRoot; + expectVersion: number | null; + batch?: ReleaseBatch; + expectBatchState?: BatchState[]; + log?: OrgLog; +} + +export const emptyRoot = (accountId: string, repositoryId: number, now: string): OrganizerRoot => ({ + accountId, + repositoryId, + version: 0, + currentSeq: null, + nextSeq: 1, + waitReason: null, + waitDetail: null, + updatedAt: now, +}); + +// Factory の実行のレーンの名前。式は契約の organizerLaneName(ランナーの laneName と共有) +export const laneOf = (run: { sprintRunId: string }, loopName: string | null) => organizerLaneName(run.sprintRunId, loopName); + +const overlaps = (a: number[], b: number[]) => a.some((n) => b.includes(n)); + +// 範囲に入る依頼か。Pull Request が範囲に 1 つでも入るか、Pull Request の無い依頼はまとめた配布が受けた依頼であること +export function inRange(request: OrgRequest, prs: number[], batch: ReleaseBatch): boolean { + if (request.prs.length === 0) return batch.lanes.some((l) => l.requestIds.includes(request.requestId)); + return overlaps(request.prs, prs); +} + +// まとめた配布の終わり方で答える依頼を決める(I61)。配布済みは結果の prs、差し戻しは最後の提示の prs、 +// 提示の前の差し戻しはまとめた配布が受けた依頼すべて。どれにも入らない依頼は次のまとめた配布へ残る +export function requestsToAnswer(batch: ReleaseBatch, requests: OrgRequest[], end: "released" | "rejected", settledPrs: number[] = []): OrgRequest[] { + const open = requests.filter((r) => r.answer === null); + if (end === "released") return open.filter((r) => inRange(r, settledPrs, batch)); + const last = batch.presentations.at(-1); + if (!last) { + const received = new Set(batch.lanes.flatMap((l) => l.requestIds)); + return open.filter((r) => received.has(r.requestId)); + } + return open.filter((r) => inRange(r, last.prs, batch)); +} + +// 止まった配布の選択肢(I63)。main へのマージの後は continue だけ +export const choicesOf = (batch: Pick): ("continue" | "reject")[] => (batch.mainMerged ? ["continue"] : ["continue", "reject"]); + +// 止まる前の状態(ジョブが失敗した時点の state から)。presented は提示を受けた後にジョブが落ちたとき +// (ランナーが release.presented を送った後の状態の保存に失敗したなど)で、承認ゲート 2 に届いていない +export function stalledFromOf(state: BatchState): StalledFrom | null { + if (state === "composed" || state === "presented" || state === "represented" || state === "confirmed") return state; + return null; +} + +// 「続きから流す」の再開の手(#2439 の設計の「止まったときと再開」)。confirmed は本番のプランに手を付けた跡があれば continue +export function resumeStepOf(batch: ReleaseBatch, startedRelease: boolean): ReleaseStep { + return stepFrom(batch.stalledFrom, batch, startedRelease); +} + +// 止まる前(か今)の状態から流し直す手。判断(承認ゲートの外の問い)への答えの再開も同じ規則で決める +// presented は受けた最後の提示のプランを流し直す(提示 1 なら present、提示 2 なら represent。状態は presented のまま承認ゲート 2 を待つ) +export function stepFrom(state: StalledFrom | null, batch: Pick, startedRelease: boolean): ReleaseStep { + if (state === "composed") return "present"; + if (state === "represented") return "represent"; + if (state === "presented") return batch.presentations.at(-1)?.k === 2 ? "represent" : "present"; + if (startedRelease || batch.mainMerged) return "continue"; + return batch.confirmedK === 2 ? "confirm:2" : "confirm:1"; +} + +// 提示の版(本番のプランの枝 release/v<版>)。確定した提示があればその版、無ければ最後の提示の版 +export function presentedVersion(batch: ReleaseBatch): string | null { + const k = batch.confirmedK; + const p = k ? batch.presentations.find((x) => x.k === k) : batch.presentations.at(-1); + return p?.version ?? null; +} + +// 配布の実行の NDF の版: 範囲の依頼の版のうち最も新しいもの(下限とは比べない) +export function newestVersion(versions: string[], fallback: string): string { + const parse = (v: string) => v.split("@")[0]!.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : -1)); + let best: string | null = null; + for (const v of versions) { + if (best === null) { + best = v; + continue; + } + const a = parse(v); + const b = parse(best); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x !== y) { + if (x > y) best = v; + break; + } + } + } + return best ?? fallback; +} diff --git a/factory/api/src/progress/append.test.ts b/factory/api/src/progress/append.test.ts index ededcddef..14c518cc2 100644 --- a/factory/api/src/progress/append.test.ts +++ b/factory/api/src/progress/append.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it } from "vitest"; -import type { IssueRequestResolvedEvent, JobFinishedEvent } from "@ndf-factory/contracts"; -import { appendControlEventTo, appendIssueRequestResolvedTo, orderKey } from "./append.ts"; +import type { IssueRequestResolvedEvent, JobFinishedEvent, ProgressEvent, ReleaseAnsweredEvent } from "@ndf-factory/contracts"; +import { appendControlEventTo, appendIssueRequestResolvedTo, appendReleaseAnsweredTo, orderKey } from "./append.ts"; import { ProgressEventError } from "./errors.ts"; import { MemoryProgressEventStore } from "./memory-store.ts"; -import { isCallerRole } from "./authorize.ts"; +import { authorizeRunner, isCallerRole } from "./authorize.ts"; import { verifyWebhookSignature } from "./github.ts"; let store: MemoryProgressEventStore; @@ -93,6 +93,43 @@ describe("appendIssueRequestResolved(#2216 の決定 10、I48)", () => { }); }); +describe("配布の事象の出所と鍵(#2439)", () => { + const answered = (patch: Partial = {}): ReleaseAnsweredEvent => ({ + sprint_run_id: "sr-1", + idempotency_key: "3:sr-1", + type: "release.answered", + at: "2026-11-02T02:00:00Z", + source: "control", + job_id: null, + schema_version: 1, + payload: { seq: 3, request_id: "run-sr-1-r1", result: "released", version: "10.28.0" }, + ...patch, + }); + + it("release.answered は範囲の実行 1 本に 1 件だけ残り、鍵が : でなければ forbidden", async () => { + expect(await appendReleaseAnsweredTo(store, answered())).toEqual({ written: true }); + expect(await appendReleaseAnsweredTo(store, answered())).toEqual({ written: false }); + expect(await errorOf(appendReleaseAnsweredTo(store, answered({ idempotency_key: "3:sr-2" })))).toBe("forbidden"); + expect(await errorOf(appendReleaseAnsweredTo(store, answered({ job_id: "job-1" })))).toBe("forbidden"); + }); + + it("ランナーは配布の事象を決まった鍵で書け、release.answered は書けない", () => { + const job = { jobId: "job-1", sprintRunId: "sr-1", repo: "o/r" } as never; + const ev = (type: string, key: string, payload: Record) => + ({ sprint_run_id: "sr-1", idempotency_key: key, type, at: "2026-11-02T02:00:00Z", source: "runner", job_id: "job-1", schema_version: 1, payload }) as unknown as ProgressEvent; + const requested = { request_id: "run-sr-1-r1", lane: "run-sr-1", prs: [1], issues: [], ndf_version: "10.27.16", requested_at: "2026-11-02T02:00:00Z" }; + expect(authorizeRunner(ev("release.requested", "run-sr-1-r1", requested), job)).toBe(true); + expect(authorizeRunner(ev("release.requested", "job-1:3", requested), job)).toBe(false); + expect(authorizeRunner(ev("release.settled", "sr-1:settled:failed", { seq: 1, result: "failed", prs: [], lanes: [] }), job)).toBe(true); + // 失敗の後の再開の成功は別の鍵(同じ実行の failed の鍵では受けない) + expect(authorizeRunner(ev("release.settled", "sr-1:settled:released", { seq: 1, result: "released", prs: [], lanes: [] }), job)).toBe(true); + expect(authorizeRunner(ev("release.settled", "sr-1:settled:failed", { seq: 1, result: "released", prs: [], lanes: [] }), job)).toBe(false); + expect(authorizeRunner(ev("release.settled", "1:settled", { seq: 1, result: "failed", prs: [], lanes: [] }), job)).toBe(false); + expect(authorizeRunner(ev("release.progress", "sr-1:progress:tagged", { seq: 1, stage: "tagged" }), job)).toBe(true); + expect(authorizeRunner(ev("release.answered", "1:sr-1", { seq: 1, result: "released" }), job)).toBe(false); + }); +}); + describe("並びの鍵(決定 6)", () => { it("小数の桁が違う時刻をミリ秒の 3 桁にそろえ、文字列の順が時刻の順になる", () => { const keys = ["2026-10-30T00:00:00.5Z", "2026-10-30T00:00:00Z", "2026-10-30T00:00:00.123456Z"].map((at) => diff --git a/factory/api/src/progress/append.ts b/factory/api/src/progress/append.ts index ef0b0fd2f..f4275a8c4 100644 --- a/factory/api/src/progress/append.ts +++ b/factory/api/src/progress/append.ts @@ -1,5 +1,5 @@ // 追記。受け口と appendControlEvent が同じ関数を通り、同じ重複の拒否で 1 件を残す(I10) -import type { IssueRequestResolvedEvent, JobFinishedEvent, ProgressEvent } from "@ndf-factory/contracts"; +import type { IssueRequestResolvedEvent, JobFinishedEvent, ProgressEvent, ReleaseAnsweredEvent } from "@ndf-factory/contracts"; import { authorizeControl } from "./authorize.ts"; import { ProgressEventError } from "./errors.ts"; import { prRef } from "./github.ts"; @@ -46,3 +46,14 @@ export async function appendIssueRequestResolvedTo( } return appendEvent(store, checked.event); } + +// Factory のオーガナイザー(#2439)が範囲の実行の進捗の記録へ release.answered(source は control、鍵は :)を +// 直に追記する。appendControlEventTo と同じ 3 つを通る。false は同じ答えが既にある +export async function appendReleaseAnsweredTo(store: ProgressEventStore, event: ReleaseAnsweredEvent): Promise<{ written: boolean }> { + const checked = checkBuiltEvent(event); + if (!checked.ok) throw new ProgressEventError("invalid_event", checked.message); + if (checked.event.type !== "release.answered" || !authorizeControl(checked.event)) { + throw new ProgressEventError("forbidden", "ここで書けるのは : の release.answered だけです"); + } + return appendEvent(store, checked.event); +} diff --git a/factory/api/src/progress/authorize.ts b/factory/api/src/progress/authorize.ts index 498726e20..393b84bff 100644 --- a/factory/api/src/progress/authorize.ts +++ b/factory/api/src/progress/authorize.ts @@ -6,8 +6,14 @@ import type { IssueRequestedEvent, IssueRequestResolvedEvent, ProgressEvent, + ReleaseAnsweredEvent, + ReleasePresentedEvent, + ReleaseProgressEvent, + ReleaseRequestedEvent, + ReleaseSettledEvent, StepRecordedEvent, } from "@ndf-factory/contracts"; +import { releasePresentedKey, releaseProgressKey, releaseSettledKey } from "@ndf-factory/contracts"; import { repoKey } from "./github.ts"; import type { VerifiedRunJob } from "./run-job-token.ts"; import type { SavedAnswerReader } from "./saved-answers.ts"; @@ -28,10 +34,15 @@ export const WRITABLE_TYPES: Record> = { "section.started", "loop.lap_closed", "loop.stopped", + // 配布の依頼と配布の実行の事象(#2439)。Factory のオーガナイザーが停止の後の処理で読む + "release.requested", + "release.presented", + "release.progress", + "release.settled", ]), api: new Set(["gate.answered"]), github: new Set(["pr.updated", "ci.updated"]), - control: new Set(["job.finished", "issue.request_resolved"]), + control: new Set(["job.finished", "issue.request_resolved", "release.answered"]), }; const canWrite = (source: EventSource, event: ProgressEvent) => @@ -40,9 +51,32 @@ const canWrite = (source: EventSource, event: ProgressEvent) => // 実行ジョブの終わりの冪等の鍵。ランナーと実行の制御が同じ鍵を使い、先の 1 つだけが残る export const finishedKey = (jobId: string) => `${jobId}:finished`; -// ランナーの冪等の鍵の形(I27): job.finished は :finished、ほかは :(seq は 1 以上) +// 配布の事象の冪等の鍵(#2439)。依頼は依頼の id、配布の実行の事象は実行の識別子を頭にする(式は契約が持ち、 +// ランナーの送信と同じ式で照らす)。ランナーが送り直しても 1 件になる +export const releaseRequestedKey = (requestId: string) => requestId; +export { releasePresentedKey, releaseProgressKey, releaseSettledKey }; +export const releaseAnsweredKey = (seq: number, sprintRunId: string) => `${seq}:${sprintRunId}`; + +function releaseKeyOf(event: ProgressEvent): string | null { + switch (event.type) { + case "release.requested": + return releaseRequestedKey((event as ReleaseRequestedEvent).payload.request_id); + case "release.presented": + return releasePresentedKey(event.sprint_run_id, (event as ReleasePresentedEvent).payload.k); + case "release.progress": + return releaseProgressKey(event.sprint_run_id, (event as ReleaseProgressEvent).payload.stage); + case "release.settled": + return releaseSettledKey(event.sprint_run_id, (event as ReleaseSettledEvent).payload.result); + default: + return null; + } +} + +// ランナーの冪等の鍵の形(I27): job.finished は :finished、配布の事象は releaseKeyOf、ほかは :(seq は 1 以上) function isRunnerKey(event: ProgressEvent, jobId: string): boolean { if (event.type === "job.finished") return event.idempotency_key === finishedKey(jobId); + const releaseKey = releaseKeyOf(event); + if (releaseKey !== null) return event.idempotency_key === releaseKey; const prefix = `${jobId}:`; return event.idempotency_key.startsWith(prefix) && /^[1-9][0-9]*$/.test(event.idempotency_key.slice(prefix.length)); } @@ -78,7 +112,12 @@ export const resolvedKey = (requestKey: string) => `${requestKey}:resolved`; // control: job.finished は鍵が :finished(決定 9)。issue.request_resolved は request_key が // そのジョブの依頼の鍵(:)で、鍵が :resolved(#2216) +// release.answered はジョブの外の事象(job_id は null)で、鍵が :(#2439) export function authorizeControl(event: ProgressEvent): boolean { + if (event.type === "release.answered") { + const { seq } = (event as ReleaseAnsweredEvent).payload; + return canWrite("control", event) && event.job_id === null && event.idempotency_key === releaseAnsweredKey(seq, event.sprint_run_id); + } if (!canWrite("control", event) || event.job_id === null) return false; if (event.type === "job.finished") return event.idempotency_key === finishedKey(event.job_id); const { request_key } = (event as IssueRequestResolvedEvent).payload; diff --git a/factory/api/src/progress/read.ts b/factory/api/src/progress/read.ts index 630fc2c4d..313eb18b6 100644 --- a/factory/api/src/progress/read.ts +++ b/factory/api/src/progress/read.ts @@ -1,7 +1,19 @@ // 進捗の記録の読み出し(#2186 の実行の制御が使う)。読むだけで、保存の型に書き換えを足さない import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; -import type { JobFinishedEvent, LoopLapClosedPayload, LoopStoppedPayload, UsageRecordedEvent } from "@ndf-factory/contracts"; +import type { + GateAnsweredPayload, + GateReachedPayload, + JobFinishedEvent, + LoopLapClosedPayload, + LoopStoppedPayload, + ReleasePresentedPayload, + ReleaseProgressPayload, + ReleaseRequestedPayload, + ReleaseSettledPayload, + StepRecordedEvent, + UsageRecordedEvent, +} from "@ndf-factory/contracts"; import { finishedKey } from "./authorize.ts"; import type { MemoryProgressEventStore } from "./memory-store.ts"; import type { ProgressEventRecord } from "./store.ts"; @@ -16,8 +28,110 @@ export interface ProgressReader { // ループの詳細の画面: 実行が送った締めた周回と、実行ごとの usage.recorded の費用の和・区間の数(section.started の数)。 // 実行ごとに事象を 1 回だけ読む loopRunsDetail(sprintRunIds: string[]): Promise; + // 実行が送った配布の事象(#2439)。jobId を渡すとそのジョブの分だけ + findReleaseEvents(sprintRunId: string, jobId?: string): Promise; + // 実行の最後の gate.reached(一覧の承認の依頼の要約。#2439)。無ければ null + findLastGate(sprintRunId: string): Promise<{ at: string; jobId: string | null; payload: GateReachedPayload } | null>; + // 承認の依頼への答え(gate.answered。#2439 の配布のまとめが読む)。無ければ null + findGateAnswer(sprintRunId: string, approvalRequestId: string): Promise; + // ---- スプリントの一覧と詳細(#2172)。どれも本文(step.recorded の files・decisions・output)を返さない ---- + // 実行の事象を order_key の順に。step.recorded は新しい方から stepLimit 件までで、ほかの種類はすべて(I11) + listRunEvents(sprintRunId: string, stepLimit: number): Promise; + // 最後の phase.changed・最後の step.recorded・最後の事象(一覧の行に足す 4 項目のもと) + latestOfRun(sprintRunId: string): Promise; + // 冪等の鍵が指す、その実行の step.recorded(本文を含む)。別の実行の鍵・別の種類は null + getStep(sprintRunId: string, idempotencyKey: string): Promise; + // payload.step が step の step.recorded(order_key の順) + listStepsNamed(sprintRunId: string, step: string): Promise; } +export type StepRecord = StepRecordedEvent & ProgressEventRecord; + +export interface RunEvents { + events: ProgressEventRecord[]; + // 上限で読まなかった step.recorded の数 + omittedSteps: number; +} + +export interface RunLatest { + phase: ProgressEventRecord | null; + step: StepRecord | null; + last: ProgressEventRecord | null; +} + +// 工程の中身の本文。詳細の読み直しで返さない(決定 10) +const STEP_BODY = ["files", "files_omitted", "commit_range", "decisions", "output"] as const; + +const isStep = (e: ProgressEventRecord): e is StepRecord => e.type === "step.recorded"; + +// 本文を外した写し。外したことは has_details で分かるよう、持っていた項目の名前を残す +export function withoutBody(e: T): T & { body_fields?: string[] } { + if (!isStep(e)) return e; + const payload = { ...e.payload } as Record; + const present = STEP_BODY.filter((k) => payload[k] !== undefined); + for (const k of STEP_BODY) delete payload[k]; + return { ...e, payload, ...(present.length > 0 ? { body_fields: present } : {}) } as T & { body_fields?: string[] }; +} + +// order_key の順の列から、古い step.recorded を上限の外として捨てる +export function capSteps(ordered: ProgressEventRecord[], stepLimit: number): RunEvents { + const total = ordered.filter(isStep).length; + let skip = Math.max(0, total - stepLimit); + const omittedSteps = skip; + const events: ProgressEventRecord[] = []; + for (const e of ordered) { + if (isStep(e) && skip > 0) { + skip -= 1; + continue; + } + events.push(withoutBody(e)); + } + return { events, omittedSteps }; +} + +// order_key の順の列の最後の phase.changed・step.recorded・事象 +export function latestOf(ordered: ProgressEventRecord[]): RunLatest { + const step = ordered.filter(isStep).at(-1); + return { + phase: ordered.filter((e) => e.type === "phase.changed").at(-1) ?? null, + step: step ? withoutBody(step) : null, + last: ordered.at(-1) ?? null, + }; +} + +const byOrderKey = (a: ProgressEventRecord, b: ProgressEventRecord) => (a.order_key < b.order_key ? -1 : a.order_key > b.order_key ? 1 : 0); + +export interface ReleaseEvents { + requested: ReleaseRequestedPayload[]; + presented: ReleasePresentedPayload[]; + progress: ReleaseProgressPayload[]; + settled: ReleaseSettledPayload | null; +} + +const RELEASE_TYPES = ["release.requested", "release.presented", "release.progress", "release.settled"]; +const GATE_TYPES = ["gate.reached", "gate.answered"]; + +const releaseEventsOf = (records: ProgressEventRecord[], jobId?: string): ReleaseEvents => { + const own = (records as Rec[]).filter((e) => jobId === undefined || e.job_id === jobId); + const of = (type: string) => own.filter((e) => e.type === type).map((e) => e.payload as unknown as T); + return { + requested: of("release.requested"), + presented: of("release.presented"), + progress: of("release.progress"), + settled: of("release.settled").at(-1) ?? null, + }; +}; + +const lastGateOf = (records: ProgressEventRecord[]) => { + const e = (records as Rec[]).filter((x) => x.type === "gate.reached").at(-1); + return e ? { at: e.at, jobId: e.job_id, payload: e.payload as unknown as GateReachedPayload } : null; +}; + +const gateAnswerOf = (records: ProgressEventRecord[], approvalRequestId: string) => { + const e = (records as Rec[]).filter((x) => x.type === "gate.answered" && x.payload.approval_request_id === approvalRequestId).at(-1); + return e ? (e.payload as unknown as GateAnsweredPayload) : null; +}; + export interface LoopRunsDetail { laps: LoopLapClosedPayload[]; summaries: Record; @@ -83,6 +197,32 @@ export function memoryProgressReader(store: MemoryProgressEventStore): ProgressR async loopRunsDetail(sprintRunIds) { return detailOf(sprintRunIds.map((id) => [id, store.listRun(id)])); }, + async findReleaseEvents(sprintRunId, jobId) { + return releaseEventsOf(store.listRun(sprintRunId), jobId); + }, + async findLastGate(sprintRunId) { + return lastGateOf(store.listRun(sprintRunId)); + }, + async findGateAnswer(sprintRunId, approvalRequestId) { + return gateAnswerOf(store.listRun(sprintRunId), approvalRequestId); + }, + async listRunEvents(sprintRunId, stepLimit) { + return capSteps(store.listRun(sprintRunId), stepLimit); + }, + async latestOfRun(sprintRunId) { + return latestOf(store.listRun(sprintRunId)); + }, + async getStep(sprintRunId, idempotencyKey) { + const found = store.listRun(sprintRunId).find((e) => e.idempotency_key === idempotencyKey); + return found && isStep(found) ? structuredClone(found) : null; + }, + async listStepsNamed(sprintRunId, step) { + return store + .listRun(sprintRunId) + .filter(isStep) + .filter((e) => e.payload.step === step) + .map(withoutBody); + }, }; } @@ -168,4 +308,82 @@ export class DynamoProgressReader implements ProgressReader { ); return detailOf(perRun); } + + async findReleaseEvents(sprintRunId: string, jobId?: string) { + return releaseEventsOf(await this.listTypes(sprintRunId, RELEASE_TYPES), jobId); + } + + async findLastGate(sprintRunId: string) { + return lastGateOf(await this.listTypes(sprintRunId, ["gate.reached"])); + } + + async findGateAnswer(sprintRunId: string, approvalRequestId: string) { + return gateAnswerOf(await this.listTypes(sprintRunId, GATE_TYPES), approvalRequestId); + } + + // 表に type の索引が無いため、実行の事象を全ページ読んでから手元で古い step.recorded を捨てる。 + // ScanIndexForward=false と Limit で切ると古い gate.reached・usage.recorded を落とすため、その読み方はしない(決定 9) + async listRunEvents(sprintRunId: string, stepLimit: number): Promise { + const out: ProgressEventRecord[] = []; + let start: Record | undefined; + do { + const page = await this.doc.send( + new QueryCommand({ + TableName: this.eventsTable, + KeyConditionExpression: "sprint_run_id = :r", + ExpressionAttributeValues: { ":r": sprintRunId }, + ExclusiveStartKey: start, + ConsistentRead: true, + }), + ); + // 本文はページごとに外し、手元に持つ量を抑える + out.push(...((page.Items ?? []) as ProgressEventRecord[]).map(withoutBody)); + start = page.LastEvaluatedKey; + } while (start); + return capSteps(out.sort(byOrderKey), stepLimit); + } + + // 新しい方から読み、最後の phase.changed と step.recorded がそろったら止める + async latestOfRun(sprintRunId: string): Promise { + const latest: RunLatest = { phase: null, step: null, last: null }; + let start: Record | undefined; + do { + const page = await this.doc.send( + new QueryCommand({ + TableName: this.eventsTable, + KeyConditionExpression: "sprint_run_id = :r", + ExpressionAttributeValues: { ":r": sprintRunId }, + ScanIndexForward: false, + ExclusiveStartKey: start, + ConsistentRead: true, + }), + ); + for (const e of (page.Items ?? []) as ProgressEventRecord[]) { + latest.last ??= e; + if (!latest.phase && e.type === "phase.changed") latest.phase = e; + if (!latest.step && isStep(e)) latest.step = withoutBody(e); + } + start = latest.phase && latest.step ? undefined : page.LastEvaluatedKey; + } while (start); + return latest; + } + + async getStep(sprintRunId: string, idempotencyKey: string): Promise { + const key = await this.doc.send(new GetCommand({ TableName: this.idempotencyTable, Key: { idempotency_key: idempotencyKey }, ConsistentRead: true })); + const orderKey = key.Item?.order_key as string | undefined; + if (!orderKey || key.Item?.sprint_run_id !== sprintRunId) return null; + const out = await this.doc.send( + new GetCommand({ TableName: this.eventsTable, Key: { sprint_run_id: sprintRunId, order_key: orderKey }, ConsistentRead: true }), + ); + const item = out.Item as ProgressEventRecord | undefined; + return item && isStep(item) ? item : null; + } + + async listStepsNamed(sprintRunId: string, step: string): Promise { + return (await this.listTypes(sprintRunId, ["step.recorded"])) + .filter(isStep) + .filter((e) => e.payload.step === step) + .sort(byOrderKey) + .map(withoutBody); + } } diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts index 1fc0a09da..60de82223 100644 --- a/factory/api/src/routes/factory.test.ts +++ b/factory/api/src/routes/factory.test.ts @@ -74,9 +74,11 @@ beforeEach(async () => { 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: (s: "ranking" | "recent", i: { number: number; title: string }[]) => model.fn(s, i), + reader: memoryProgressReader(h.progress), }; app = createApiApp(() => ({ verifier: tokens.verifier, accounts }), (e) => logs.push(e), () => null, () => factory); h.github.installations.set(9, new Map([[101, "org-a/repo-a"], [102, "org-a/repo-b"]])); @@ -226,3 +228,26 @@ describe("課題の候補(受け入れ条件 9・10)", () => { expect(res.candidates.map((c: { number: number }) => c.number)).toEqual([9, 8]); }); }); + +describe("Factory のオーガナイザーの経路(#2439 の AC 9・11、I66)", () => { + it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 409、承認の依頼へ答える経路は無い", 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); + }); + + 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); + }); +}); diff --git a/factory/api/src/routes/factory.ts b/factory/api/src/routes/factory.ts index 0d846440f..71b41254f 100644 --- a/factory/api/src/routes/factory.ts +++ b/factory/api/src/routes/factory.ts @@ -1,25 +1,31 @@ -// 画面向けの経路(#2186・#2347)。取り込み・キーの持ち込み・課題の候補・スプリントの実行・開発ループ。 +// 画面向けの経路(#2186・#2347・#2172)。取り込み・キーの持ち込み・課題の候補・スプリントの実行・開発ループ。 // どの経路も resolveFromRequest でアカウントを解決し、他人のアカウントは #2170 の I18 と同じ 403 import type { Context, Hono } from "hono"; import { resolveFromRequest, 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"; +import type { OrganizerControl } from "../organizer/control.ts"; +import type { ProgressReader } from "../progress/read.ts"; import type { RepositoryService } from "../repositories/service.ts"; 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 { registerSprintViews, sprintRunList } from "./sprint-views.ts"; export interface FactoryDeps { repositories: RepositoryService; credentials: CredentialService; control: RunControl; loops: LoopControl; + organizer: OrganizerControl; table: RunsTable; github: GitHubPort; candidateModel: CandidateModel; + // 進捗の記録の読み出し(スプリントの一覧と詳細。#2172) + reader: ProgressReader; } function positiveInt(raw: string | undefined): number { @@ -97,9 +103,12 @@ export function registerFactory(app: Hono, deps: () => FactoryDeps): vo app.get("/accounts/:accountId/sprint-runs", async (c) => { const ctx = await ctxOf(c); const raw = c.req.query("repository_id"); - return c.json({ sprint_runs: await deps().control.listSprintRuns(ctx, raw === undefined ? null : positiveInt(raw)) }); + return c.json({ sprint_runs: await 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"))); @@ -140,4 +149,26 @@ export function registerFactory(app: Hono, deps: () => FactoryDeps): vo const ctx = await ctxOf(c); return c.json(await deps().loops.answer(ctx, c.req.param("loopId"), await readJson(c))); }); + + // 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")))); + }); + + app.get("/accounts/:accountId/repositories/:repositoryId/releases", async (c) => { + const ctx = await ctxOf(c); + 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)); + }); + + // 止まった配布を解く。画面の経路は 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); + }); } diff --git a/factory/api/src/routes/sprint-views.test.ts b/factory/api/src/routes/sprint-views.test.ts new file mode 100644 index 000000000..241ae60a9 --- /dev/null +++ b/factory/api/src/routes/sprint-views.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ProgressEvent } from "@ndf-factory/contracts"; +import { AccountService } from "../accounts/service.ts"; +import { MemoryAccountStore } from "../accounts/memory-store.ts"; +import { CredentialService, type SecretsPort } from "../credentials/service.ts"; +import { createApiApp } from "../handlers/api.ts"; +import type { GitHubPort } from "../github/port.ts"; +import { appendEvent } from "../progress/append.ts"; +import { memoryProgressReader } from "../progress/read.ts"; +import { RepositoryService } from "../repositories/service.ts"; +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 type { FactoryDeps } from "./factory.ts"; + +const noSecrets: SecretsPort = { create: async () => "arn", put: async () => undefined, delete: async () => undefined }; + +let h: RunsHarness; +let tokens: TokenKit; +let app: ReturnType; +let githubCalls: string[]; +let factory: FactoryDeps; + +async function call(path: string, user = "u-1"): Promise { + return (await app.request(path, { headers: { Authorization: await tokens.bearer(user) } })) as TestResponse; +} + +async function personal(user: string): Promise { + return ((await (await call("/me", user)).json()) as { personal_account_id: string }).personal_account_id; +} + +function runOf(accountId: string, patch: Partial = {}): SprintRun { + return { + sprintRunId: "sr-1", + accountId, + repositoryId: 101, + repo: "org-a/repo-a", + state: "running", + issues: [11, 12], + ndfVersion: "10.27.15", + executionMode: "byok", + modelCredentialSecretArn: null, + currentJobId: "job-1", + jobCount: 1, + cancelRequested: false, + failureReason: null, + createdBy: "u-1", + createdAt: "2026-11-04T00:00:00.000Z", + updatedAt: "2026-11-04T00:00:00.000Z", + ...patch, + }; +} + +let seq = 0; +async function emit(sprintRunId: string, type: string, payload: Record, at?: string) { + seq += 1; + const event = { + sprint_run_id: sprintRunId, + idempotency_key: `job-1:${seq}`, + type, + at: at ?? new Date(Date.UTC(2026, 10, 4, 0, 0, seq)).toISOString(), + source: "runner", + job_id: "job-1", + schema_version: 1, + payload, + } as unknown as ProgressEvent; + await appendEvent(h.progress, event); + return event.idempotency_key; +} + +beforeEach(async () => { + h = await createRunsHarness(); + tokens = await createTokenKit(); + seq = 0; + githubCalls = []; + // 偽の GitHub の口の呼び出しを数える(I13) + const github = new Proxy(h.github, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" + ? (...args: unknown[]) => { + githubCalls.push(String(prop)); + return (value as (...a: unknown[]) => unknown).apply(target, args); + } + : value; + }, + }) as GitHubPort; + const store = new MemoryAccountStore(); + let n = 0; + const accounts = new AccountService({ store, newId: () => `00000000-0000-4000-8000-${String(++n).padStart(12, "0")}` }); + const reader = memoryProgressReader(h.progress); + factory = { + repositories: new RepositoryService({ table: h.table, github, proofKey: async () => new Uint8Array(32) }), + credentials: new CredentialService(h.table, noSecrets), + control: h.control, + loops: new LoopControl({ table: h.table, control: h.control, reader, now: () => h.clock.now }), + organizer: h.organizer, + table: h.table, + github, + candidateModel: async () => [], + reader, + }; + app = createApiApp(() => ({ verifier: tokens.verifier, accounts }), () => undefined, () => null, () => factory); +}); + +async function seed() { + const acc = await personal("u-1"); + await h.table.putRegistration({ accountId: acc, repositoryId: 101, fullName: "org-a/repo-a", installationId: 9, registeredBy: "u-1", registeredAt: "2026-11-01T00:00:00Z" }); + h.table.runs.set("sr-1", runOf(acc)); + h.table.runs.set("sr-2", runOf(acc, { sprintRunId: "sr-2", issues: [20], state: "awaiting_approval", createdAt: "2026-11-04T01:00:00.000Z" })); + await emit("sr-1", "phase.changed", { phase: "実装", issues: [11] }); + const step = await emit("sr-1", "step.recorded", { + step: "impl", + exit_code: 0, + seconds: 5, + summary: "済んだ", + issues: [11], + output: { kind: "report", text: "## 作業の報告", truncated: false }, + }); + await emit("sr-2", "phase.changed", { phase: "準備", issues: [] }); + return { acc, step }; +} + +describe("一覧の経路(受け入れ条件 1〜3・18)", () => { + it("既存の 6 項目の名前と型を変えずに、repo・今のフェーズ・今の工程・最後の動きを足す。5 つに無い名前も 200", async () => { + const { acc } = await seed(); + const res = await call(`/accounts/${acc}/sprint-runs`); + expect(res.status).toBe(200); + 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({ + sprint_run_id: "sr-1", + repository_id: 101, + issues: [11, 12], + state: "running", + failure_reason: null, + created_at: "2026-11-04T00:00:00.000Z", + repo: "org-a/repo-a", + current_phase: "実装", + current_step: "impl", + last_event_at: "2026-11-04T00:00:02.000Z", + }); + expect(sprint_runs[0].current_phase).toBe("準備"); + }); +}); + +describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)", () => { + it("詳細は本文を返さず、工程の中身は 1 件ずつ本文を返す。どちらも GitHub の口を呼ばない", async () => { + const { acc, step } = await seed(); + const detail = await call(`/accounts/${acc}/sprint-runs/sr-1`); + expect(detail.status).toBe(200); + const body = await detail.json(); + 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)}`); + expect(one.status).toBe(200); + expect((await one.json()).output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); + await call(`/accounts/${acc}/sprint-runs`); + expect(githubCalls).toEqual([]); + }); + + 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(); + 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(); + expect([one.files, one.decisions, one.output]).toEqual([null, null, null]); + }); + + it("読み出しは事象の表と実行の表を書き換えない(I14)", async () => { + 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`); + expect(JSON.stringify([h.progress.events, [...h.table.runs.values()]])).toBe(before); + }); +}); + +describe("認可(受け入れ条件 11・I12)", () => { + it("他人のアカウント・無いアカウント・属さない実行・無い実行・無い工程・登録の無いリポジトリは、同じ本文の 403", async () => { + 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 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"], + ] as const; + const bodies = new Set(); + for (const [path, user] of cases) { + const res = await call(path, user); + expect(res.status, path).toBe(403); + bodies.add(await res.text()); + } + expect([...bodies]).toEqual([JSON.stringify({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } })]); + }); +}); + +describe("バックログの経路(受け入れ条件 15・16・I13)", () => { + it("順位の表と、そのリポジトリの棚卸しの記録を新しい順に返し、GitHub は順位の表の読み出しだけを呼ぶ", async () => { + const { acc } = await seed(); + 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`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.repo).toBe("org-a/repo-a"); + expect(body.ranking).toEqual({ + milestone: "25 見本", + rows: [ + { rank: 1, number: 5, title: "先に" }, + { rank: 2, number: 3, title: "後に" }, + ], + }); + expect(body.refinements.map((r: { summary: string }) => r.summary)).toEqual(["新しい棚卸し", "古い棚卸し"]); + expect(githubCalls).toEqual(["findRankingTable"]); + }); + + it("順位の表が無ければ ranking は null、GitHub の口が失敗すれば 503", async () => { + const { acc } = await seed(); + const none = await (await call(`/accounts/${acc}/repositories/101/backlog`)).json(); + 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); + }); +}); diff --git a/factory/api/src/routes/sprint-views.ts b/factory/api/src/routes/sprint-views.ts new file mode 100644 index 000000000..95407222d --- /dev/null +++ b/factory/api/src/routes/sprint-views.ts @@ -0,0 +1,121 @@ +// スプリントの一覧・詳細・工程の中身・バックログの読み出しの経路(#2172)。読むだけで、事象の表・実行の表・GitHub へ書かない(I14)。 +// アカウントは resolveFromRequest で解決し、他人のアカウント・属さない実行・無い実行・無い工程・登録の無いリポジトリは +// どれも同じ本文の 403(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13) +import type { Context, Hono } from "hono"; +import type { AccountContext } from "../accounts/model.ts"; +import { resolveFromRequest, 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"; + +// 一覧の行ごとの読み出しを並べる数の上限 +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 { + return await read(); + } catch { + throw new ServiceUnavailableError("progress_unavailable"); + } +} + +// アカウントの実行だけを返す。無い実行も他人の実行も同じ 403(getRun 1 回の後に返す) +async function ownRun(deps: FactoryDeps, ctx: AccountContext, sprintRunId: string): Promise { + const run = await deps.table.getRun(sprintRunId); + if (!run || run.accountId !== ctx.accountId) throw new ForbiddenError(); + return run; +} + +// 一覧の行(既存の 6 項目に 4 項目を足す)。既存の経路の絞り込みと並びは実行の制御が持つ。repo は一覧で読んだ実行から取り、 +// 行ごとに実行を読み直さない +export async function sprintRunList(deps: FactoryDeps, ctx: AccountContext, repositoryId: number | null) { + const runs = await deps.control.listSprintRuns(ctx, repositoryId); + return mapLimit(runs, LIST_CONCURRENCY, async (run) => { + const latest = await readOrUnavailable(() => deps.reader.latestOfRun(run.sprintRunId)); + return { + sprint_run_id: run.sprintRunId, + repository_id: run.repositoryId, + issues: run.issues, + state: run.state, + failure_reason: run.failureReason, + created_at: run.createdAt, + ...listRowExtras({ repo: run.repo }, latest), + }; + }); +} + +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)); + }); + + 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)); + }); + + 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), + }); + }); +} + +function rankingOf(table: RankingTable | null) { + if (!table) return null; + return { milestone: table.milestone, rows: table.rows.map((r, i) => ({ rank: i + 1, number: r.number, title: r.title })) }; +} diff --git a/factory/api/src/runs/control.test.ts b/factory/api/src/runs/control.test.ts index 6252ca9d4..ec656f7ae 100644 --- a/factory/api/src/runs/control.test.ts +++ b/factory/api/src/runs/control.test.ts @@ -188,7 +188,7 @@ describe("停止の知らせ・再開(受け入れ条件 15・19・20、I8・I await finishJob(jobId, sprintRunId, "gate"); expect((await h.table.getRun(sprintRunId))!).toMatchObject({ state: "awaiting_approval", currentJobId: null }); expect(h.platform.tokens.size).toBe(0); - const { jobId: next } = await h.control.requestResume({ sprintRunId, approvalRequestId: "ar-1" }); + const next = (await h.control.requestResume({ sprintRunId, approvalRequestId: "ar-1" })).jobId!; const instruction = h.platform.taskOf(next)![1].instruction; expect(instruction).toMatchObject({ START: "resume", APPROVAL_REQUEST_ID: "ar-1", NDF_VERSION: "10.26.1" }); expect(instruction.ISSUES).toBeUndefined(); diff --git a/factory/api/src/runs/control.ts b/factory/api/src/runs/control.ts index 853c713c2..edef472e5 100644 --- a/factory/api/src/runs/control.ts +++ b/factory/api/src/runs/control.ts @@ -7,11 +7,13 @@ import { judgeLimits } from "../accounts/limits.ts"; import type { AccountContext } from "../accounts/model.ts"; import { 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"; -import type { ProgressReader } from "../progress/read.ts"; +import type { ProgressReader, ReleaseEvents } from "../progress/read.ts"; import { signRunJobToken, type RunJobSigningKey } from "../progress/run-job-token.ts"; import { loopWriteAfterRun, loopWriteOnResume, type RunOutcome } from "./loops.ts"; import { + isReleaseRun, isTerminalJob, isTerminalLoop, isTerminalRun, @@ -20,7 +22,9 @@ import { type FailureReason, type LoopJobInput, type LoopWrite, + type FinishReason, type Registration, + type ReleaseStep, type RunJob, type SprintRun, } from "./model.ts"; @@ -98,6 +102,16 @@ export interface LoopHooks { sweepLoops(): Promise; } +// Factory のオーガナイザーが渡す口(#2439)。循環を避けるため、組み立ての後に差し込む +export interface OrganizerHooks { + newRequestRows(run: SprintRun, events: ReleaseEvents): Promise; + tick(accountId: string, repositoryId: number): Promise; + decide(run: SprintRun, approvalRequestId: string): Promise; + // 配布の実行のまとめた配布が main へマージ済みか(取り消しを受けても止まった配布として残すかを決める。I63) + releaseMerged(run: SprintRun): Promise; + sweep(): Promise; +} + export type ResumeRejectedCode = "not_found" | "not_awaiting_approval" | "sprint_run_busy"; export class ResumeRejectedError extends Error { @@ -112,7 +126,7 @@ export interface StartInput { issues: unknown; } -// 手元のランナーの申告(#2189 の登録と心拍。契約の local-runner-declaration) +// 手元のマシンの申告(#2189 の登録と心拍。契約の local-runner-declaration) export interface LocalRunner { runnerId: string; maxJobs?: number; @@ -140,6 +154,7 @@ export class RunControl { private readonly newId: () => string; private readonly log: (entry: Record) => void; private hooks: LoopHooks | null = null; + private organizer: OrganizerHooks | null = null; constructor(private readonly deps: RunControlDeps) { this.now = deps.now ?? (() => new Date()); @@ -151,6 +166,10 @@ export class RunControl { this.hooks = hooks; } + setOrganizer(organizer: OrganizerHooks): void { + this.organizer = organizer; + } + get config(): RunControlConfig { return this.deps.config; } @@ -303,6 +322,10 @@ export class RunControl { return { ...run, legacy }; } if (result === "loop_changed") return "loop_changed"; + // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62) + if (result === "release_running") { + throw new ConflictError("parallel_unsupported_version", "このリポジトリでは、並行を許さない NDF の版の実行と並べて動かせません"); + } if (result === "issue_in_use") { const held = new Set((await table.listIssueShares(ctx.accountId, registration.repositoryId)).map((s) => s.number)); throw new ConflictError("issue_in_use", "課題は同じリポジトリの別のスプリントの実行が持っています", { numbers: opts.issues.filter((n) => held.has(n)) }); @@ -388,6 +411,11 @@ export class RunControl { }; if (job.start === "new") instruction.ISSUES = run.issues.join(","); if (job.approvalRequestId) instruction.APPROVAL_REQUEST_ID = job.approvalRequestId; + if (job.start === "release") { + instruction.RELEASE_SEQ = String(run.releaseSeq ?? 1); + instruction.RELEASE_STEP = job.releaseStep ?? "present"; + } + if (job.releaseRequestId) instruction.RELEASE_REQUEST_ID = job.releaseRequestId; if (run.executionMode === "byok" && run.modelCredentialSecretArn) instruction.MODEL_CREDENTIAL_SECRET_ARN = run.modelCredentialSecretArn; const loop = job.loopInput; if (loop) { @@ -409,6 +437,42 @@ export class RunControl { return signRunJobToken({ jobId: job.jobId, sprintRunId: run.sprintRunId, repo: run.repo, expiresAt }, await this.deps.signingKey()); } + // Factory のオーガナイザーが配布の実行を起こす(組んだ直後と、起動の前で止まったときの起こし直し。#2439) + launchJob(run: SprintRun, job: RunJob): Promise { + return this.launch(run, job); + } + + // まとめた配布の配布の実行とその最初のジョブ(#2439 の I57・I64)。実行の枠を持たず、使える量の判定にかけない。 + // 形態・キー・始めた利用者は範囲の実行(base)から写す + buildReleaseRun(base: SprintRun, seq: number, ndfVersion: string): { run: SprintRun; job: RunJob } { + const now = this.iso(); + const run: SprintRun = { + sprintRunId: this.newId(), + accountId: base.accountId, + repositoryId: base.repositoryId, + repo: base.repo, + state: "starting", + issues: [], + ndfVersion, + executionMode: base.executionMode, + modelCredentialSecretArn: base.modelCredentialSecretArn, + currentJobId: null, + jobCount: 1, + cancelRequested: false, + failureReason: null, + createdBy: base.createdBy, + createdAt: now, + updatedAt: now, + legacy: false, + purpose: "release", + releaseSeq: seq, + }; + const job = this.newJob(run, 1, "release", null); + job.releaseStep = "present"; + run.currentJobId = job.jobId; + return { run, job }; + } + // 起動。local は渡す口へ積むだけ(#2189 が claimLocalRunJob で受け取る)。起動できなければジョブと実行を failed にする private async launch(run: SprintRun, job: RunJob): Promise { if (job.runnerKind === "local") return; @@ -471,17 +535,40 @@ export class RunControl { if (!run) return; const finished = await this.ensureFinished(run, job, reason); const result = finished.payload.result; - const finishReason = finished.payload.reason ?? null; + const rawReason = finished.payload.reason ?? null; + // release_requested は gate の理由(手動の実行が配布の依頼の答えを待つ。#2439 の I65)で、ジョブの終わりの理由には写さない + const finishReason: FinishReason | null = rawReason === "release_requested" ? null : rawReason; const now = this.iso(); + // 配布の依頼と配布の実行の事象(#2439)。受けた依頼の行は実行の移りと同じトランザクションで書く(I59) + const releaseEvents = this.organizer ? await this.deps.reader.findReleaseEvents(run.sprintRunId, job.jobId) : null; + const requests = releaseEvents && this.organizer && !isReleaseRun(run) ? await this.organizer.newRequestRows(run, releaseEvents) : []; + const lastRequest = releaseEvents?.requested.at(-1)?.request_id ?? null; // 実行と枠を先に移し、ジョブを終わりの状態にするのは最後にする。途中で止まってもジョブは見回りの対象に残り、 // 次の見回りか停止の知らせの再送が同じ結果でやり直す(実行の移し替えは条件つきで、2 度目は何もしない) + // main へマージした後の配布の実行は取り消しを受けても終わらせず、止まった配布として「続きから流す」で仕上げられるようにする(I63) + const keepRelease = run.cancelRequested && isReleaseRun(run) && this.organizer !== null && (await this.organizer.releaseMerged(run)); if (run.currentJobId === job.jobId) { - if (run.cancelRequested) { + if (run.cancelRequested && !keepRelease) { const lw = await this.loopWriteFor(run, job.jobId, { runState: "cancelled", failureReason: null, finishReason }); - if (await table.finishRun(run.sprintRunId, "cancelled", null, now, { finishReason, loop: lw?.write })) await this.afterRunMoved(run, lw?.write, true); + if (await table.finishRun(run.sprintRunId, "cancelled", null, now, { finishReason, loop: lw?.write, requests })) await this.afterRunMoved(run, lw?.write, true); + } else if (isReleaseRun(run)) { + // 配布の実行は、配布済みで done、承認ゲートか判断で承認待ち、それ以外は止まった配布として承認待ち(I65・決定 11) + if (releaseEvents?.settled?.result === "released") { + await table.finishRun(run.sprintRunId, "done", null, now); + } else if (result === "gate" && !keepRelease) { + await table.awaitApproval(run.sprintRunId, job.jobId, now); + } else { + // 取り消しを受けた実行は取り消しの印を外す(続けたジョブの終わりで取り消しにしない) + await table.awaitApproval(run.sprintRunId, job.jobId, now, { awaitingReason: "release_stalled", ...(keepRelease ? { clearCancel: true } : {}) }); + } } else if (result === "gate") { const lw = await this.loopWriteFor(run, job.jobId, { runState: "awaiting_approval", failureReason: null, finishReason }); - await table.awaitApproval(run.sprintRunId, job.jobId, now, { loop: lw?.write }); + const waitsRelease = rawReason === "release_requested" && !run.loopId && lastRequest !== null; + await table.awaitApproval(run.sprintRunId, job.jobId, now, { + loop: lw?.write, + requests, + ...(waitsRelease ? { awaitingReason: "release_requested" as const, releaseRequestId: lastRequest } : {}), + }); } else if (result === "stopped" && finishReason === "usage_limit" && run.executionMode === "local") { // local の利用上限はリセット時刻の後に同じ周回を続ける(I54。ほかの形態は failed として扱う) const resumeAt = this.resumeAtOf(finished); @@ -490,9 +577,15 @@ export class RunControl { } else { const { state, failure } = runEndOf(result, finishReason, job, finished, reason); const lw = await this.loopWriteFor(run, job.jobId, { runState: state, failureReason: failure, finishReason }); - if (await table.finishRun(run.sprintRunId, state, failure, now, { finishReason, loop: lw?.write })) await this.afterRunMoved(run, lw?.write, true); + if (await table.finishRun(run.sprintRunId, state, failure, now, { finishReason, loop: lw?.write, requests })) await this.afterRunMoved(run, lw?.write, true); } } + // 依頼を受けたか配布の実行が動いたら、配布のまとめを進める。落ちてよい(見回りが続ける) + if (this.organizer && (isReleaseRun(run) || requests.length > 0)) { + await this.organizer.tick(run.accountId, run.repositoryId).catch((err: Error) => + this.log({ event: "organizer_tick_failed", sprint_run_id: run.sprintRunId, error: err.name }), + ); + } const updated = await table.updateJob(job.jobId, { state: result, result, finishedAt: now }, ["pending", "running"]); if (!updated) return; if (job.tokenSecretArn) { @@ -560,6 +653,7 @@ export class RunControl { await this.resumeAfterReset(run).catch((err: Error) => this.log({ event: "reset_resume_failed", sprint_run_id: run.sprintRunId, error: err.name })); } await this.hooks?.sweepLoops().catch((err: Error) => this.log({ event: "loop_sweep_failed", error: err.name })); + await this.organizer?.sweep().catch((err: Error) => this.log({ event: "organizer_sweep_failed", error: err.name })); } // リセット待ちの実行に、承認の依頼の識別子の無い resume のジョブを作る(決定 7)。時刻の前は何もしない @@ -589,8 +683,9 @@ export class RunControl { return loopInput(loop, exclude); } - // 再開の要求(#2173)。承認待ちで動くジョブが無いときだけ resume のジョブを作り起動する(I8・I9) - async requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string }> { + // 再開の要求(#2173)。承認待ちで動くジョブが無いときだけ resume のジョブを作り起動する(I8・I9)。 + // 配布の実行は、配布のまとめが答えから再開の手を決める。差し戻しはジョブを起こさず jobId が null(#2439) + async requestResume(input: { sprintRunId: string; approvalRequestId: string }): Promise<{ jobId: string | null }> { const { table, reader } = this.deps; const run = await table.getRun(input.sprintRunId); if (!run) throw new ResumeRejectedError("not_found"); @@ -602,8 +697,17 @@ export class RunControl { throw new ResumeRejectedError("not_awaiting_approval"); } if (run.currentJobId !== null) throw new ResumeRejectedError("sprint_run_busy"); + // 配布の依頼の答えを待つ実行と止まった配布は、承認の依頼の答えでは再開しない(配布のまとめが再開する) + if (run.awaitingReason) throw new ResumeRejectedError("not_awaiting_approval"); const seq = run.jobCount + 1; - const job = this.newJob(run, seq, "resume", input.approvalRequestId); + let releaseStep: ReleaseStep | null = null; + if (isReleaseRun(run)) { + if (!this.organizer) throw new ServiceUnavailableError("sprint_run_unavailable"); + releaseStep = await this.organizer.decide(run, input.approvalRequestId); + if (releaseStep === null) return { jobId: null }; + } + const job = this.newJob(run, seq, releaseStep ? "release" : "resume", input.approvalRequestId); + if (releaseStep) job.releaseStep = releaseStep; job.loopInput = await this.resumeLoopInput(run); // ループは答えで running へ戻る(同じトランザクション) const loop = run.loopId ? await table.getLoop(run.accountId, run.loopId) : null; @@ -616,12 +720,42 @@ export class RunControl { return { jobId: job.jobId }; } + // 手動の実行の配布の答えの再開(#2439)。実行がこの依頼を待つときだけ、条件つきの書き込みで resume のジョブを作る。 + // 待っていなければ not_waiting(取り消し・終わり・別の理由や別の依頼での承認待ち)、ジョブが動いていれば busy + async resumeForRelease(sprintRunId: string, requestId: string): Promise<"resumed" | "busy" | "not_waiting"> { + const { table } = this.deps; + const run = await table.getRun(sprintRunId); + const waits = (r: SprintRun | null) => r !== null && r.state === "awaiting_approval" && r.awaitingReason === "release_requested" && r.releaseRequestId === requestId; + if (!waits(run)) return run && !isTerminalRun(run.state) && run.currentJobId !== null ? "busy" : "not_waiting"; + const job = this.newJob(run!, run!.jobCount + 1, "resume", null); + job.releaseRequestId = requestId; + if (!(await table.addResumeJob(job, this.iso(), { expectAwaiting: { reason: "release_requested", releaseRequestId: requestId } }))) { + return waits(await table.getRun(sprintRunId)) ? "busy" : "not_waiting"; + } + await this.launch(run!, job); + return "resumed"; + } + + // 止まった配布の実行を「続きから流す」(#2439 の F7)。止まった配布の承認待ちのときだけ書く。配布の実行は作り直さない(I57) + // organizer を渡すと、まとめた配布の状態の戻しを同じトランザクションで書く(間に見回りが止まった印を書き戻さない) + async resumeRelease(run: SprintRun, step: ReleaseStep, organizer?: OrganizerWrite): Promise { + const job = this.newJob(run, run.jobCount + 1, "release", null); + job.releaseStep = step; + if (!(await this.deps.table.addResumeJob(job, this.iso(), { expectAwaiting: { reason: "release_stalled" }, ...(organizer ? { organizer } : {}) }))) return false; + await this.launch(run, job); + return true; + } + // 取り消し。動くジョブがあれば止め、停止の後に cancelled へ移す。無ければその場で cancelled にする async cancelSprintRun(ctx: AccountContext, sprintRunId: string): Promise<{ state: "cancelled" | "running" }> { const { table, platform } = this.deps; const run = await table.getRun(sprintRunId); if (!run || run.accountId !== ctx.accountId) throw new NotFoundError("sprint_run_not_found", "スプリントの実行が見つかりません"); if (isTerminalRun(run.state)) throw new ConflictError("already_finished", "このスプリントの実行は終わっています"); + // main へマージした後の配布の実行は取り消さない(取り消すと配布の仕上げも次の配布も進まない。I63) + if (run.currentJobId === null && isReleaseRun(run) && this.organizer && (await this.organizer.releaseMerged(run))) { + throw new ConflictError("release_after_main", "main へマージした配布は取り消せません。止まった配布は続きから流してください"); + } if (run.currentJobId === null) { const lw = await this.loopWriteFor(run, null, { runState: "cancelled", failureReason: null, finishReason: null }); if (await table.finishRun(run.sprintRunId, "cancelled", null, this.iso(), { loop: lw?.write })) { @@ -641,23 +775,14 @@ export class RunControl { return { state: "running" }; } - async listSprintRuns(ctx: AccountContext, repositoryId: number | null) { + // 一覧に載せる実行(絞り込みと並び)。行の形は一覧の経路(routes/sprint-views.ts)が作る + async listSprintRuns(ctx: AccountContext, repositoryId: number | null): Promise { const runs = await this.deps.table.listRuns(ctx.accountId, repositoryId === null ? 50 : 1000); - return runs - .filter((r) => repositoryId === null || r.repositoryId === repositoryId) - .slice(0, 50) - .map((r) => ({ - sprint_run_id: r.sprintRunId, - repository_id: r.repositoryId, - issues: r.issues, - state: r.state, - failure_reason: r.failureReason, - created_at: r.createdAt, - })); + return runs.filter((r) => repositoryId === null || r.repositoryId === repositoryId).slice(0, 50); } // 形態 local の渡す口(#2189)。最も古い pending の local のジョブを running にし、指示と token を 1 回だけ返す。 - // ランナーの申告があれば、そのランナーの枠が同時のジョブの数に達している間は渡さない(I50。申告が無ければ 1) + // 手元のマシンの申告があれば、そのマシンの枠が同時のジョブの数に達している間は渡さない(I50。申告が無ければ 1) async claimLocalRunJob(accountId: string, runner?: LocalRunner): Promise<{ instruction: RunJobInstruction; token: string } | null> { const { table } = this.deps; for (const job of await table.listActiveJobs()) { @@ -684,7 +809,7 @@ export class RunControl { return null; } - // 手元のランナーの終わりか心拍の切れ(#2189)。停止の知らせと同じ処理 + // 手元のマシンのジョブの終わりか心拍の切れ(#2189)。停止の知らせと同じ処理 async reportLocalRunJobEnded(jobId: string): Promise { const job = await this.deps.table.getJob(jobId); if (!job || job.runnerKind !== "local" || isTerminalJob(job.state)) return; diff --git a/factory/api/src/runs/deps.ts b/factory/api/src/runs/deps.ts index f460ec598..750f4118d 100644 --- a/factory/api/src/runs/deps.ts +++ b/factory/api/src/runs/deps.ts @@ -4,7 +4,8 @@ import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-sec import type { JWK } from "jose"; import { CredentialService, AwsSecrets } from "../credentials/service.ts"; import { GitHubApp } from "../github/app.ts"; -import { appendControlEventTo } from "../progress/append.ts"; +import { OrganizerControl } from "../organizer/control.ts"; +import { appendControlEventTo, appendReleaseAnsweredTo } from "../progress/append.ts"; import { DynamoProgressReader } from "../progress/read.ts"; import type { RunJobSigningKey } from "../progress/run-job-token.ts"; import { progressStoreFromEnv } from "../handlers/ingest.ts"; @@ -86,7 +87,7 @@ export function factoryDepsFromEnv(): FactoryDeps & { platform: EcsRunnerPlatfor return { kid: parsed.kid, privateKey: parsed.jwk }; }; const reader = new DynamoProgressReader(requireEnv("PROGRESS_EVENTS_TABLE_NAME"), requireEnv("PROGRESS_IDEMPOTENCY_TABLE_NAME")); - // 自動で起こすもの(周回・リセット時刻の後の再開)のたびに、始めた利用者の今のプラン・形態・席を引き直す(表の名前がある処理だけ) + // 自動で起こすもの(周回・リセット時刻の後の再開)のたびに、始めた利用者の今の料金プラン・形態・席を引き直す(表の名前がある処理だけ) const accountsTable = process.env.ACCOUNTS_TABLE_NAME; const resolveContext = accountsTable ? accountContextResolver(new AccountService({ store: new DynamoAccountStore(accountsTable) })) : undefined; const control = new RunControl({ @@ -103,6 +104,9 @@ export function factoryDepsFromEnv(): FactoryDeps & { platform: EcsRunnerPlatfor // ループの制御は実行の制御の停止の後の処理と見回りから呼ばれる(常駐のプロセスを持たない。#2347 の決定 1) const loops = new LoopControl({ table, control, reader, resolveContext }); control.setLoopHooks(loops.hooks()); + // Factory のオーガナイザーも同じく、停止の後の処理・再開の要求・見回りから呼ばれる(#2439 の決定 2) + const organizer = new OrganizerControl({ table, github, reader, control, appendAnswered: (e) => appendReleaseAnsweredTo(progressStoreFromEnv(), e) }); + control.setOrganizer(organizer); // 候補の証明の鍵は App のクライアントの秘密から用途を分けて作る const proofKey = async () => new Uint8Array(createHash("sha256").update(`ndf-factory:repositories:${await clientSecret()}`).digest()); cached = { @@ -110,9 +114,11 @@ export function factoryDepsFromEnv(): FactoryDeps & { platform: EcsRunnerPlatfor credentials: new CredentialService(table, new AwsSecrets(sm)), control, loops, + organizer, table, github, candidateModel: bedrockCandidateModel(requireEnv("CANDIDATE_MODEL_ID")), + reader, platform, }; return cached; diff --git a/factory/api/src/runs/dynamo-table.ts b/factory/api/src/runs/dynamo-table.ts index 68119f9c4..8a96af074 100644 --- a/factory/api/src/runs/dynamo-table.ts +++ b/factory/api/src/runs/dynamo-table.ts @@ -1,5 +1,5 @@ // 実行の表の本物(DynamoDB の 1 つの表 RUNS_TABLE_NAME)。行の形は #2186・#2347 の設計の「データ構造」。 -// 枠と実行の増減は 1 回のトランザクションで書き、条件で失敗したら書かない(I34)。開発ループ・持ち分・手元のランナーの枠も +// 枠と実行の増減は 1 回のトランザクションで書き、条件で失敗したら書かない(I34)。開発ループ・持ち分・手元のマシンの枠も // 同じトランザクションで書く(#2347 の決定 2・11・12) import { DynamoDBClient, TransactionCanceledException } from "@aws-sdk/client-dynamodb"; import { @@ -12,6 +12,7 @@ import { TransactWriteCommand, UpdateCommand, } from "@aws-sdk/lib-dynamodb"; +import type { OrganizerRoot, OrganizerWrite, OrgLog, OrgRequest, ReleaseAnswer, ReleaseBatch } from "../organizer/model.ts"; import type { CreateRunGuards, CreateRunResult, @@ -26,7 +27,7 @@ import type { SprintRun, } from "./model.ts"; import { isTerminalJob, milestoneHash } from "./model.ts"; -import type { RunsTable, RunTransitionExtras } from "./table.ts"; +import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; export const GSI1 = "gsi1"; const ACTIVE_JOBS = "ACTIVE_JOBS"; @@ -46,6 +47,12 @@ const runnerSk = (id: string) => `RUNNER#${id}`; const WAITING_LOOPS = "WAITING_LOOPS"; const AWAITING_RESET = "AWAITING_RESET"; const MIGRATION = { pk: "MIGRATION", sk: "MIGRATED#2347" }; +// Factory のオーガナイザー(#2439)。ORGANIZER_ACTIVE は流れている根と届け終えていない依頼だけが載る gsi1 の疎な鍵 +const orgSk = (r: number) => `ORG#${r}`; +const orgReqSk = (r: number, id: string) => `ORGREQ#${r}#${id}`; +const orgBatchSk = (r: number, seq: number) => `ORGBATCH#${r}#${String(seq).padStart(6, "0")}`; +const orgLogSk = (log: OrgLog) => `ORGLOG#${log.repositoryId}#${log.at}#${log.kind}#${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`; +const ORGANIZER_ACTIVE = "ORGANIZER_ACTIVE"; const SLOT_RETRIES = 3; // トランザクションの何番目の項目が条件で落ちたか @@ -171,8 +178,13 @@ export class DynamoRunsTable implements RunsTable { async getSlots(accountId: string, repositoryId: number): Promise { const account = await this.get<{ count: number }>(accountPk(accountId), "ACTIVE"); - const repository = await this.get<{ count: number; legacyCount?: number }>(accountPk(accountId), repoSlotSk(repositoryId)); - return { account: account?.count ?? 0, repository: repository?.count ?? 0, legacy: repository?.legacyCount ?? 0 }; + const repository = await this.get<{ count: number; legacyCount?: number; legacySeq?: number }>(accountPk(accountId), repoSlotSk(repositoryId)); + return { + account: account?.count ?? 0, + repository: repository?.count ?? 0, + legacy: repository?.legacyCount ?? 0, + ...(repository?.legacySeq !== undefined ? { legacySeq: repository.legacySeq } : {}), + }; } private slotPut(pk: string, sk: string, expected: number) { @@ -201,8 +213,8 @@ export class DynamoRunsTable implements RunsTable { async createRun(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): Promise { const repoSlot = this.slotPut(accountPk(run.accountId), repoSlotSk(run.repositoryId), expected.repository); if (guards?.legacy) { - // 古い版の実行は legacyCount を数える(I48) - repoSlot.Update.UpdateExpression += ", legacyCount = if_not_exists(legacyCount, :lz) + :lone"; + // 古い版の実行は legacyCount を数え、数えに入れた回数 legacySeq を 1 増やす(I48・#2439 の I62) + repoSlot.Update.UpdateExpression += ", legacyCount = if_not_exists(legacyCount, :lz) + :lone, legacySeq = if_not_exists(legacySeq, :lz) + :lone"; Object.assign(repoSlot.Update.ExpressionAttributeValues, { ":lz": 0, ":lone": 1 }); } else if (guards) { // 新しい版の実行は、古い版の実行がリポジトリに無いときだけ書く @@ -243,12 +255,25 @@ export class DynamoRunsTable implements RunsTable { if (run.loopId) { items.push({ Put: { TableName: this.tableName, Item: { pk: accountPk(run.accountId), sk: loopRunSk(run.loopId, run.sprintRunId), sprintRunId: run.sprintRunId } } }); } + // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62。オーガナイザーの根を読むだけ) + const organizerAt = items.length; + if (guards?.legacy) { + items.push({ + ConditionCheck: { + TableName: this.tableName, + Key: { pk: accountPk(run.accountId), sk: orgSk(run.repositoryId) }, + ConditionExpression: "attribute_not_exists(currentSeq) OR attribute_type(currentSeq, :nul)", + ExpressionAttributeValues: { ":nul": "NULL" }, + }, + }); + } try { await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); return "created"; } catch (err) { const failed = failedIndexes(err); if (failed.length === 0 && !isConditionFailure(err)) throw err; + 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"; if (failed.some((i) => i >= issueStart)) return "issue_in_use"; @@ -379,23 +404,35 @@ export class DynamoRunsTable implements RunsTable { ); } - addResumeJob(job: RunJob, now: string, opts: { from?: "awaiting_approval" | "awaiting_reset"; loop?: LoopWrite } = {}) { + addResumeJob(job: RunJob, now: string, opts: ResumeOptions = {}) { const from = opts.from ?? "awaiting_approval"; + // 承認待ちの理由(#2439)。照らす理由が無ければ理由が空のときだけ書く + const values: Record = { ":starting": "starting", ":awaiting": from, ":j": job.jobId, ":seq": job.seq, ":now": now, ":null": "NULL", ":nul": null }; + let reasonCond = "(attribute_not_exists(awaitingReason) OR attribute_type(awaitingReason, :null))"; + if (opts.expectAwaiting) { + reasonCond = "awaitingReason = :ar"; + values[":ar"] = opts.expectAwaiting.reason; + if (opts.expectAwaiting.releaseRequestId !== undefined) { + reasonCond += " AND releaseRequestId = :rr"; + values[":rr"] = opts.expectAwaiting.releaseRequestId; + } + } const items: Record[] = [ { Update: { TableName: this.tableName, Key: { pk: runPk(job.sprintRunId), sk: "RUN" }, - UpdateExpression: "SET #s = :starting, currentJobId = :j, jobCount = :seq, updatedAt = :now, resumeAt = :nul", - ConditionExpression: "#s = :awaiting AND attribute_type(currentJobId, :null)", + UpdateExpression: "SET #s = :starting, currentJobId = :j, jobCount = :seq, updatedAt = :now, resumeAt = :nul, awaitingReason = :nul, releaseRequestId = :nul", + ConditionExpression: `#s = :awaiting AND attribute_type(currentJobId, :null) AND ${reasonCond}`, ExpressionAttributeNames: { "#s": "state" }, - ExpressionAttributeValues: { ":starting": "starting", ":awaiting": from, ":j": job.jobId, ":seq": job.seq, ":now": now, ":null": "NULL", ":nul": null }, + ExpressionAttributeValues: values, }, }, { Put: { TableName: this.tableName, Item: this.jobItem(job), ConditionExpression: "attribute_not_exists(pk)" } }, ]; if (from === "awaiting_reset") items.push({ Delete: { TableName: this.tableName, Key: { pk: runPk(job.sprintRunId), sk: "RESET" } } }); if (opts.loop) items.push(...this.loopItems(opts.loop)); + if (opts.organizer) items.push(...this.organizerItems(opts.organizer)); return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: items as never }))); } @@ -409,7 +446,9 @@ export class DynamoRunsTable implements RunsTable { Update: { TableName: this.tableName, Key: { pk: runPk(sprintRunId), sk: "RUN" }, - UpdateExpression: `SET #s = :awaiting, currentJobId = :null, updatedAt = :now${extras.finishReason !== undefined ? ", finishReason = :fr" : ""}`, + UpdateExpression: `SET #s = :awaiting, currentJobId = :null, updatedAt = :now, awaitingReason = :ar, releaseRequestId = :rr${ + extras.finishReason !== undefined ? ", finishReason = :fr" : "" + }${extras.clearCancel ? ", cancelRequested = :false" : ""}`, ConditionExpression: "currentJobId = :j AND NOT #s IN (:t1, :t2, :t3, :t4)", ExpressionAttributeNames: { "#s": "state" }, ExpressionAttributeValues: { @@ -417,12 +456,16 @@ export class DynamoRunsTable implements RunsTable { ":null": null, ":now": now, ":j": jobId, + ":ar": extras.awaitingReason ?? null, + ":rr": extras.releaseRequestId ?? null, ...this.terminalValues(), ...(extras.finishReason !== undefined ? { ":fr": extras.finishReason } : {}), + ...(extras.clearCancel ? { ":false": false } : {}), }, }, }, ...(extras.loop ? this.loopItems(extras.loop) : []), + ...this.requestItems(extras.requests), ]; return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: items as never }))); } @@ -489,7 +532,7 @@ export class DynamoRunsTable implements RunsTable { Update: { TableName: this.tableName, Key: { pk: runPk(sprintRunId), sk: "RUN" }, - UpdateExpression: `SET #s = :state, failureReason = :reason, currentJobId = :null, updatedAt = :now, resumeAt = :null${ + UpdateExpression: `SET #s = :state, failureReason = :reason, currentJobId = :null, updatedAt = :now, resumeAt = :null, awaitingReason = :null, releaseRequestId = :null${ extras.finishReason !== undefined ? ", finishReason = :fr" : "" }`, ConditionExpression: `NOT #s IN (:t1, :t2, :t3, :t4) AND ${legacyCond}`, @@ -505,8 +548,8 @@ export class DynamoRunsTable implements RunsTable { }, }, }, - decrement("ACTIVE"), - decrement(repoSlotSk(run.repositoryId), run.legacy === true), + // 配布の実行は枠を持たない(#2439 の I64) + ...(run.purpose === "release" ? [] : [decrement("ACTIVE"), decrement(repoSlotSk(run.repositoryId), run.legacy === true)]), ...shares.map((x) => ({ Delete: { TableName: this.tableName, @@ -517,6 +560,7 @@ export class DynamoRunsTable implements RunsTable { })), ...(run.state === "awaiting_reset" ? [{ Delete: { TableName: this.tableName, Key: { pk: runPk(sprintRunId), sk: "RESET" } } }] : []), ...(extras.loop ? this.loopItems(extras.loop) : []), + ...this.requestItems(extras.requests), ]; try { await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); @@ -527,7 +571,8 @@ export class DynamoRunsTable implements RunsTable { // 実行の行の条件で落ち、読み直して終わりの状態なら 2 度目の移しで何もしない const now2 = await this.getRun(sprintRunId); if (!now2 || TERMINAL.includes(now2.state)) return false; - if (failed.some((i) => i >= 3 && i < 3 + shares.length) || (now2.legacy !== run.legacy)) continue; + const shareStart = run.purpose === "release" ? 1 : 3; + if (failed.some((i) => i >= shareStart && i < shareStart + shares.length) || now2.legacy !== run.legacy) continue; return false; } } @@ -572,7 +617,7 @@ export class DynamoRunsTable implements RunsTable { }); // 終わりの状態へ移すときは見回りの GSI から外す const leaving = patch.state !== undefined && isTerminalJob(patch.state); - // 手元のランナーが受け取ったジョブは、ランナーの枠を同じ書き込みで減らす(I50) + // 手元のマシンが受け取ったジョブは、そのマシンの枠を同じ書き込みで減らす(I50) const current = leaving ? await this.getJob(jobId) : null; if (current?.runnerId && current.state === "running") return this.finishLocalJob(current, patch, from); let condition = "attribute_exists(pk)"; @@ -838,7 +883,7 @@ export class DynamoRunsTable implements RunsTable { Update: { TableName: this.tableName, Key: { pk: accountPk(run.accountId), sk: repoSlotSk(run.repositoryId) }, - UpdateExpression: "SET legacyCount = if_not_exists(legacyCount, :zero) + :one", + UpdateExpression: "SET legacyCount = if_not_exists(legacyCount, :zero) + :one, legacySeq = if_not_exists(legacySeq, :zero) + :one", ExpressionAttributeValues: { ":zero": 0, ":one": 1 }, }, }, @@ -854,4 +899,191 @@ export class DynamoRunsTable implements RunsTable { ), ); } + + // ---- Factory のオーガナイザー(#2439) ---- + + private rootItem(root: OrganizerRoot): Item { + const sk = orgSk(root.repositoryId); + return { pk: accountPk(root.accountId), sk, ...root, ...(root.currentSeq !== null ? { gsi1pk: ORGANIZER_ACTIVE, gsi1sk: `${root.accountId}#${sk}` } : {}) }; + } + + // 受けた依頼の行。在る行は書かない(呼ぶ側が読んで無い行だけを渡す。I59) + private requestItems(requests: OrgRequest[] | undefined): Record[] { + return (requests ?? []).map((req) => { + const sk = orgReqSk(req.repositoryId, req.requestId); + return { + Put: { + TableName: this.tableName, + Item: { pk: accountPk(req.accountId), sk, ...req, ...(req.deliveredAt === null ? { gsi1pk: ORGANIZER_ACTIVE, gsi1sk: `${req.accountId}#${sk}` } : {}) }, + ConditionExpression: "attribute_not_exists(pk)", + }, + }; + }); + } + + private logItem(log: OrgLog): Record { + return { Put: { TableName: this.tableName, Item: { pk: accountPk(log.accountId), sk: orgLogSk(log), ...log }, ConditionExpression: "attribute_not_exists(sk)" } }; + } + + // 根(と渡されたまとめた配布・記録)の条件つきの書き込み(I56・I67) + private organizerItems(write: OrganizerWrite, extraRootCond?: string): Record[] { + const rootValues: Record = {}; + const rootNames: Record = {}; + let rootCond = "attribute_not_exists(pk)"; + if (write.expectVersion !== null) { + rootCond = "#ver = :v"; + rootNames["#ver"] = "version"; + rootValues[":v"] = write.expectVersion; + } + if (extraRootCond) { + rootCond = `(${rootCond}) AND ${extraRootCond}`; + rootValues[":nul"] = "NULL"; + } + const items: Record[] = [ + { + Put: { + TableName: this.tableName, + Item: this.rootItem(write.root), + ConditionExpression: rootCond, + ...(Object.keys(rootNames).length > 0 ? { ExpressionAttributeNames: rootNames } : {}), + ...(Object.keys(rootValues).length > 0 ? { ExpressionAttributeValues: rootValues } : {}), + }, + }, + ]; + if (write.batch) { + const batchItem = { pk: accountPk(write.batch.accountId), sk: orgBatchSk(write.batch.repositoryId, write.batch.seq), ...write.batch }; + if (write.expectBatchState) { + const values: Record = {}; + const keys = write.expectBatchState.map((s, i) => { + values[`:b${i}`] = s; + return `:b${i}`; + }); + items.push({ + Put: { + TableName: this.tableName, + Item: batchItem, + ConditionExpression: `#bs IN (${keys.join(", ")})`, + ExpressionAttributeNames: { "#bs": "state" }, + ExpressionAttributeValues: values, + }, + }); + } else { + items.push({ Put: { TableName: this.tableName, Item: batchItem, ConditionExpression: "attribute_not_exists(pk)" } }); + } + } + if (write.log) items.push(this.logItem(write.log)); + return items; + } + + getOrganizer(accountId: string, repositoryId: number) { + return this.get(accountPk(accountId), orgSk(repositoryId)); + } + + writeOrganizer(write: OrganizerWrite) { + return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: this.organizerItems(write) as never }))); + } + + composeRelease(input: ComposeRelease) { + const { write, run, job } = input; + const runItem = { pk: runPk(run.sprintRunId), sk: "RUN", gsi1pk: accountPk(run.accountId), gsi1sk: `RUN#${run.createdAt}`, ...run }; + const items: Record[] = [ + // 根は流れている配布が無いときだけ(I56) + ...this.organizerItems(write, "(attribute_not_exists(currentSeq) OR attribute_type(currentSeq, :nul))"), + { Put: { TableName: this.tableName, Item: runItem, ConditionExpression: "attribute_not_exists(pk)" } }, + { Put: { TableName: this.tableName, Item: this.jobItem(job), ConditionExpression: "attribute_not_exists(pk)" } }, + // 照らした後に古い版の実行が数えに入っていない(I62) + { + ConditionCheck: { + TableName: this.tableName, + Key: { pk: accountPk(run.accountId), sk: repoSlotSk(run.repositoryId) }, + ConditionExpression: input.legacySeq === null ? "attribute_not_exists(legacySeq)" : "legacySeq = :ls", + ...(input.legacySeq === null ? {} : { ExpressionAttributeValues: { ":ls": input.legacySeq } }), + }, + }, + ]; + return this.attempt(() => this.doc.send(new TransactWriteCommand({ TransactItems: items as never }))); + } + + async listOrgRequests(accountId: string, repositoryId: number): Promise { + const rows = await this.queryPrefix(accountPk(accountId), `ORGREQ#${repositoryId}#`); + return rows.sort((a, b) => (a.requestedAt < b.requestedAt ? -1 : a.requestedAt > b.requestedAt ? 1 : 0)); + } + + answerRequest(accountId: string, repositoryId: number, requestId: string, answer: ReleaseAnswer) { + return this.attempt(() => + this.doc.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { pk: accountPk(accountId), sk: orgReqSk(repositoryId, requestId) }, + UpdateExpression: "SET #ans = :a", + ConditionExpression: "attribute_exists(pk) AND (attribute_not_exists(#ans) OR attribute_type(#ans, :nul))", + ExpressionAttributeNames: { "#ans": "answer" }, + ExpressionAttributeValues: { ":a": answer, ":nul": "NULL" }, + }), + ), + ); + } + + async markDelivered(accountId: string, repositoryId: number, requestId: string, at: string) { + await this.attempt(() => + this.doc.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { pk: accountPk(accountId), sk: orgReqSk(repositoryId, requestId) }, + UpdateExpression: "SET deliveredAt = :at REMOVE gsi1pk, gsi1sk", + ConditionExpression: "attribute_exists(pk) AND (attribute_not_exists(deliveredAt) OR attribute_type(deliveredAt, :nul))", + ExpressionAttributeValues: { ":at": at, ":nul": "NULL" }, + }), + ), + ); + } + + getBatch(accountId: string, repositoryId: number, seq: number) { + return this.get(accountPk(accountId), orgBatchSk(repositoryId, seq)); + } + + async listBatches(accountId: string, repositoryId: number, limit: number): Promise { + const out = await this.doc.send( + new QueryCommand({ + TableName: this.tableName, + KeyConditionExpression: "pk = :pk AND begins_with(sk, :p)", + ExpressionAttributeValues: { ":pk": accountPk(accountId), ":p": `ORGBATCH#${repositoryId}#` }, + ScanIndexForward: false, + Limit: limit, + ConsistentRead: true, + }), + ); + return (out.Items ?? []).map((i) => strip(i)!); + } + + async appendOrgLog(log: OrgLog) { + await this.doc.send(new TransactWriteCommand({ TransactItems: [this.logItem(log)] as never })); + } + + listOrgLogs(accountId: string, repositoryId: number) { + return this.queryPrefix(accountPk(accountId), `ORGLOG#${repositoryId}#`); + } + + async listOrganizerActive(): Promise<{ accountId: string; repositoryId: number }[]> { + const out = new Map(); + let start: Record | undefined; + do { + const page = await this.doc.send( + new QueryCommand({ + TableName: this.tableName, + IndexName: GSI1, + KeyConditionExpression: "gsi1pk = :pk", + ExpressionAttributeValues: { ":pk": ORGANIZER_ACTIVE }, + ExclusiveStartKey: start, + }), + ); + for (const i of page.Items ?? []) { + const accountId = i.accountId as string; + const repositoryId = i.repositoryId as number; + out.set(`${accountId}#${repositoryId}`, { accountId, repositoryId }); + } + start = page.LastEvaluatedKey; + } while (start); + return [...out.values()]; + } } diff --git a/factory/api/src/runs/loops.test.ts b/factory/api/src/runs/loops.test.ts index 3dca7bf7e..7dbf744da 100644 --- a/factory/api/src/runs/loops.test.ts +++ b/factory/api/src/runs/loops.test.ts @@ -1,4 +1,4 @@ -// #2347: 開発ループ・並行する実行の取り合い・利用上限の後の再開・手元のランナーの枠(設計の「テスト設計」) +// #2347: 開発ループ・並行する実行の取り合い・利用上限の後の再開・手元のマシンの枠(設計の「テスト設計」) import { readdirSync, readFileSync } from "node:fs"; import { beforeEach, describe, expect, it } from "vitest"; import { validateRunJobInstruction, type RunJobInstruction } from "@ndf-factory/contracts"; @@ -441,7 +441,7 @@ describe("版(受け入れ条件 6、I48)と移行", () => { describe("リポジトリの同時の数(受け入れ条件 8、I49)", () => { beforeEach(() => setup()); - it("プランの値 3 で宣言 1 のリポジトリは 2 本目が concurrent_repo_runs で断られる", async () => { + it("料金プランの値 3 で宣言 1 のリポジトリは 2 本目が concurrent_repo_runs で断られる", async () => { const pro = accountContext({ plan: "pro" }); await h.table.setMaxConcurrentRuns("acc-1", REPO, 1); await manual([4], REPO, pro); @@ -450,7 +450,7 @@ describe("リポジトリの同時の数(受け入れ条件 8、I49)", () => }); }); -describe("手元のランナーの同時のジョブの数(受け入れ条件 9、I50)", () => { +describe("手元のマシンの同時のジョブの数(受け入れ条件 9、I50)", () => { beforeEach(() => setup()); it("max_jobs 1 のランナーへ 2 本目を渡さず、1 本目が終わると渡す", async () => { diff --git a/factory/api/src/runs/loops.ts b/factory/api/src/runs/loops.ts index 3df02f806..9c5711f5a 100644 --- a/factory/api/src/runs/loops.ts +++ b/factory/api/src/runs/loops.ts @@ -262,7 +262,7 @@ export class LoopControl { if (ok) return; continue; } - // 保存した写しを判定に使わない(プランの変更・席の外し・形態の変更を周回ごとに照らす) + // 保存した写しを判定に使わない(料金プランの変更・席の外し・形態の変更を周回ごとに照らす) if (this.deps.resolveContext) { const fresh = await this.deps.resolveContext(loop.accountId, loop.createdBy); if (!fresh) { diff --git a/factory/api/src/runs/memory-table.ts b/factory/api/src/runs/memory-table.ts index be1d72371..f17bbcb23 100644 --- a/factory/api/src/runs/memory-table.ts +++ b/factory/api/src/runs/memory-table.ts @@ -1,4 +1,5 @@ // テストで使う偽の実行の表。確かめと書き込みを 1 つの同期の処理で行い、条件つきの書き込みとトランザクションを再現する +import type { OrganizerRoot, OrganizerWrite, OrgLog, OrgRequest, ReleaseAnswer, ReleaseBatch } from "../organizer/model.ts"; import { isTerminalJob, isTerminalLoop, @@ -15,7 +16,7 @@ import { type Slots, type SprintRun, } from "./model.ts"; -import type { RunsTable, RunTransitionExtras } from "./table.ts"; +import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; const regKey = (a: string, r: number) => `${a}#${r}`; const shareKey = (a: string, r: number, k: string | number) => `${a}#${r}#${k}`; @@ -35,6 +36,15 @@ export class MemoryRunsTable implements RunsTable { readonly issueShares = new Map(); // # → running の local のジョブの数 readonly runnerSlots = new Map(); + // # → 古い版の実行を数えに入れた回数(#2439 の I62) + readonly legacySeqs = new Map(); + // Factory のオーガナイザー(#2439)。#(#)→ 行 + readonly organizers = new Map(); + readonly orgRequests = new Map(); + readonly batches = new Map(); + readonly orgLogs: OrgLog[] = []; + // テストで、次の composeRelease のトランザクションを落とす + failNextCompose = false; migrated = false; private async tick(): Promise { @@ -101,10 +111,12 @@ export class MemoryRunsTable implements RunsTable { await this.tick(); const key = regKey(accountId, repositoryId); const legacy = this.legacySlots.get(key) ?? 0; + const legacySeq = this.legacySeqs.get(key); return { account: this.accountSlots.get(accountId) ?? 0, repository: this.repositorySlots.get(key) ?? 0, ...(legacy !== 0 ? { legacy } : {}), + ...(legacySeq !== undefined ? { legacySeq } : {}), }; } @@ -138,6 +150,8 @@ export class MemoryRunsTable implements RunsTable { if ((this.repositorySlots.get(repoKey) ?? 0) !== expected.repository) return "slots"; if (guards) { if (!guards.legacy && (this.legacySlots.get(repoKey) ?? 0) !== 0) return "legacy"; + // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62) + if (guards.legacy && (this.organizers.get(repoKey)?.currentSeq ?? null) !== null) return "release_running"; 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"; @@ -145,7 +159,10 @@ export class MemoryRunsTable implements RunsTable { this.accountSlots.set(run.accountId, expected.account + 1); this.repositorySlots.set(repoKey, expected.repository + 1); if (guards) { - if (guards.legacy) this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) + 1); + if (guards.legacy) { + this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) + 1); + this.legacySeqs.set(repoKey, (this.legacySeqs.get(repoKey) ?? 0) + 1); + } for (const n of guards.issues) this.issueShares.set(shareKey(run.accountId, run.repositoryId, n), run.sprintRunId); if (guards.loop) this.applyLoop(guards.loop); } @@ -180,14 +197,23 @@ export class MemoryRunsTable implements RunsTable { if (r && r.state === "starting") Object.assign(r, { state: "running", updatedAt: now }); } - async addResumeJob(job: RunJob, now: string, opts: { from?: "awaiting_approval" | "awaiting_reset"; loop?: LoopWrite } = {}) { + async addResumeJob(job: RunJob, now: string, opts: ResumeOptions = {}) { await this.tick(); const r = this.runs.get(job.sprintRunId); if (!r || r.state !== (opts.from ?? "awaiting_approval") || r.currentJobId !== null) return false; + const expect = opts.expectAwaiting; + if (expect) { + if (r.awaitingReason !== expect.reason) return false; + if (expect.releaseRequestId !== undefined && r.releaseRequestId !== expect.releaseRequestId) return false; + } else if ((r.awaitingReason ?? null) !== null) { + return false; + } if (opts.loop && !this.loopFits(opts.loop)) return false; - Object.assign(r, { state: "starting", currentJobId: job.jobId, jobCount: job.seq, updatedAt: now, resumeAt: null }); + if (opts.organizer && !this.organizerFits(opts.organizer)) return false; + Object.assign(r, { state: "starting", currentJobId: job.jobId, jobCount: job.seq, updatedAt: now, resumeAt: null, awaitingReason: null, releaseRequestId: null }); this.jobs.set(job.jobId, structuredClone(job)); if (opts.loop) this.applyLoop(opts.loop); + if (opts.organizer) this.applyOrganizer(opts.organizer); return true; } @@ -196,9 +222,17 @@ export class MemoryRunsTable implements RunsTable { const r = this.runs.get(sprintRunId); if (!r || isTerminalRun(r.state) || r.currentJobId !== jobId) return false; if (extras.loop && !this.loopFits(extras.loop)) return false; - Object.assign(r, { state: "awaiting_approval", currentJobId: null, updatedAt: now }); + Object.assign(r, { + state: "awaiting_approval", + currentJobId: null, + updatedAt: now, + awaitingReason: extras.awaitingReason ?? null, + releaseRequestId: extras.releaseRequestId ?? null, + }); + if (extras.clearCancel) r.cancelRequested = false; if (extras.finishReason !== undefined) r.finishReason = extras.finishReason; if (extras.loop) this.applyLoop(extras.loop); + this.putRequests(extras.requests); return true; } @@ -225,14 +259,18 @@ export class MemoryRunsTable implements RunsTable { const r = this.runs.get(sprintRunId); if (!r || isTerminalRun(r.state)) return false; if (extras.loop && !this.loopFits(extras.loop)) return false; - Object.assign(r, { state, failureReason: reason, currentJobId: null, updatedAt: now, resumeAt: null }); + Object.assign(r, { state, failureReason: reason, currentJobId: null, updatedAt: now, resumeAt: null, awaitingReason: null, releaseRequestId: null }); if (extras.finishReason !== undefined) r.finishReason = extras.finishReason; const repoKey = regKey(r.accountId, r.repositoryId); - this.accountSlots.set(r.accountId, (this.accountSlots.get(r.accountId) ?? 0) - 1); - this.repositorySlots.set(repoKey, (this.repositorySlots.get(repoKey) ?? 0) - 1); - if (r.legacy === true) this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) - 1); + // 配布の実行は枠を持たない(#2439 の I64) + if (r.purpose !== "release") { + this.accountSlots.set(r.accountId, (this.accountSlots.get(r.accountId) ?? 0) - 1); + this.repositorySlots.set(repoKey, (this.repositorySlots.get(repoKey) ?? 0) - 1); + if (r.legacy === true) this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) - 1); + } for (const [key, owner] of this.issueShares) if (owner === sprintRunId) this.issueShares.delete(key); if (extras.loop) this.applyLoop(extras.loop); + this.putRequests(extras.requests); return true; } @@ -356,7 +394,123 @@ export class MemoryRunsTable implements RunsTable { r.legacy = true; const repoKey = regKey(r.accountId, r.repositoryId); this.legacySlots.set(repoKey, (this.legacySlots.get(repoKey) ?? 0) + 1); + this.legacySeqs.set(repoKey, (this.legacySeqs.get(repoKey) ?? 0) + 1); for (const n of issues) this.issueShares.set(shareKey(r.accountId, r.repositoryId, n), r.sprintRunId); return true; } + + // ---- Factory のオーガナイザー(#2439) ---- + + private putRequests(requests: OrgRequest[] | undefined): void { + for (const req of requests ?? []) { + const key = shareKey(req.accountId, req.repositoryId, req.requestId); + if (!this.orgRequests.has(key)) this.orgRequests.set(key, structuredClone(req)); + } + } + + private organizerFits(write: OrganizerWrite): boolean { + const key = regKey(write.root.accountId, write.root.repositoryId); + const current = this.organizers.get(key); + if (write.expectVersion === null ? current !== undefined : current?.version !== write.expectVersion) return false; + if (write.expectBatchState && write.batch) { + const b = this.batches.get(shareKey(write.batch.accountId, write.batch.repositoryId, write.batch.seq)); + if (!b || !write.expectBatchState.includes(b.state)) return false; + } + return true; + } + + private applyOrganizer(write: OrganizerWrite): void { + this.organizers.set(regKey(write.root.accountId, write.root.repositoryId), structuredClone(write.root)); + if (write.batch) this.batches.set(shareKey(write.batch.accountId, write.batch.repositoryId, write.batch.seq), structuredClone(write.batch)); + if (write.log) this.orgLogs.push(structuredClone(write.log)); + } + + async getOrganizer(accountId: string, repositoryId: number) { + await this.tick(); + return structuredClone(this.organizers.get(regKey(accountId, repositoryId)) ?? null); + } + + async writeOrganizer(write: OrganizerWrite) { + await this.tick(); + if (!this.organizerFits(write)) return false; + this.applyOrganizer(write); + return true; + } + + async composeRelease(input: ComposeRelease) { + await this.tick(); + if (this.failNextCompose) { + this.failNextCompose = false; + throw new Error("TransactionCanceled"); + } + const { write, run, job } = input; + const key = regKey(write.root.accountId, write.root.repositoryId); + if (!this.organizerFits(write)) return false; + const current = this.organizers.get(key); + if (current && current.currentSeq !== null) return false; + if ((this.legacySeqs.get(key) ?? null) !== input.legacySeq) return false; + if (this.batches.has(shareKey(write.batch.accountId, write.batch.repositoryId, write.batch.seq))) return false; + this.applyOrganizer(write); + this.runs.set(run.sprintRunId, structuredClone(run)); + this.jobs.set(job.jobId, structuredClone(job)); + return true; + } + + async listOrgRequests(accountId: string, repositoryId: number) { + await this.tick(); + return [...this.orgRequests.values()] + .filter((r) => r.accountId === accountId && r.repositoryId === repositoryId) + .sort((a, b) => (a.requestedAt < b.requestedAt ? -1 : a.requestedAt > b.requestedAt ? 1 : 0)) + .map((r) => structuredClone(r)); + } + + async answerRequest(accountId: string, repositoryId: number, requestId: string, answer: ReleaseAnswer) { + await this.tick(); + const r = this.orgRequests.get(shareKey(accountId, repositoryId, requestId)); + if (!r || r.answer !== null) return false; + r.answer = structuredClone(answer); + return true; + } + + async markDelivered(accountId: string, repositoryId: number, requestId: string, at: string) { + await this.tick(); + const r = this.orgRequests.get(shareKey(accountId, repositoryId, requestId)); + if (r && r.deliveredAt === null) r.deliveredAt = at; + } + + async getBatch(accountId: string, repositoryId: number, seq: number) { + await this.tick(); + return structuredClone(this.batches.get(shareKey(accountId, repositoryId, seq)) ?? null); + } + + async listBatches(accountId: string, repositoryId: number, limit: number) { + await this.tick(); + return [...this.batches.values()] + .filter((b) => b.accountId === accountId && b.repositoryId === repositoryId) + .sort((a, b) => b.seq - a.seq) + .slice(0, limit) + .map((b) => structuredClone(b)); + } + + async appendOrgLog(log: OrgLog) { + await this.tick(); + this.orgLogs.push(structuredClone(log)); + } + + async listOrgLogs(accountId: string, repositoryId: number) { + await this.tick(); + return this.orgLogs.filter((l) => l.accountId === accountId && l.repositoryId === repositoryId).map((l) => structuredClone(l)); + } + + async listOrganizerActive() { + await this.tick(); + const out = new Map(); + for (const root of this.organizers.values()) { + if (root.currentSeq !== null) out.set(regKey(root.accountId, root.repositoryId), { accountId: root.accountId, repositoryId: root.repositoryId }); + } + for (const req of this.orgRequests.values()) { + if (req.deliveredAt === null) out.set(regKey(req.accountId, req.repositoryId), { accountId: req.accountId, repositoryId: req.repositoryId }); + } + return [...out.values()]; + } } diff --git a/factory/api/src/runs/model.ts b/factory/api/src/runs/model.ts index bdaaea146..33a79a0b8 100644 --- a/factory/api/src/runs/model.ts +++ b/factory/api/src/runs/model.ts @@ -22,7 +22,7 @@ export interface Registration { installationId: number; registeredBy: string; registeredAt: string; - // 同時の実行の数の宣言。空は「宣言しない(プランの値)」(I49) + // 同時の実行の数の宣言。空は「宣言しない(料金プランの値)」(I49) maxConcurrentRuns?: number | null; } @@ -62,8 +62,24 @@ export interface SprintRun { finishReason?: FinishReason | null; // awaiting_reset の間だけ。リセット時刻 resumeAt?: string | null; + // 実行の目的(#2439)。release は Factory のオーガナイザーが起こす配布の実行で、実行の枠を持たない(I64)。空は sprint + purpose?: RunPurpose | null; + // 配布の実行のまとめた配布の連番。purpose が空なら空 + releaseSeq?: number | null; + // awaiting_approval の理由(I65)。空は承認の依頼の待ち + awaitingReason?: AwaitingReason | null; + // 手動の実行が待つ配布の依頼の id。awaitingReason が release_requested のときだけ + releaseRequestId?: string | null; } +export type RunPurpose = "release"; +// release_requested は配布の依頼の答えを待つ手動の実行、release_stalled はジョブが失敗して止まった配布の実行 +export type AwaitingReason = "release_requested" | "release_stalled"; +// 配布の実行のジョブの手(#2439) +export const RELEASE_STEPS = ["present", "represent", "confirm:1", "confirm:2", "continue"] as const; +export type ReleaseStep = (typeof RELEASE_STEPS)[number]; +export const isReleaseRun = (run: Pick) => run.purpose === "release"; + export type FinishReason = "section_limit" | "usage_limit"; // ループの実行のジョブの指示へ載せる値。ジョブを作る時点で写す(指示の組み立てを表の読みから切り離す) @@ -80,12 +96,16 @@ export interface RunJob { sprintRunId: string; accountId: string; seq: number; - start: "new" | "resume" | "loop"; + start: "new" | "resume" | "loop" | "release"; approvalRequestId: string | null; // ループの実行だけ(#2347) loopInput?: LoopJobInput | null; - // local で受け取った手元のランナー。手元のランナーの枠を減らす先(I50) + // local で受け取った手元のマシン。手元のマシンの枠を減らす先(I50) runnerId?: string | null; + // 配布の実行のジョブの手(#2439)。ほかのジョブは空 + releaseStep?: ReleaseStep | null; + // 手動の実行の配布の答えの再開で、答えの出た依頼の id(#2439) + releaseRequestId?: string | null; state: JobState; runnerKind: "ecs" | "local"; taskArn: string | null; @@ -104,6 +124,8 @@ export interface Slots { account: number; repository: number; legacy?: number; + // 古い版の実行を legacy へ数えに入れた回数(#2439 の I62)。無いのは「まだ数えに入れていない」 + legacySeq?: number; } // ---- 開発ループ(#2347) ---- @@ -193,7 +215,8 @@ export interface CreateRunGuards { loop?: LoopWrite; } -export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed"; +// release_running は、古い版の実行の開始で、リポジトリのまとめた配布が流れていた(#2439 の I62) +export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running"; // マイルストーンの持ち分の行の鍵に使う、題の sha256 の先頭 32 字 export const milestoneHash = (title: string) => createHash("sha256").update(title, "utf8").digest("hex").slice(0, 32); diff --git a/factory/api/src/runs/runner-api.test.ts b/factory/api/src/runs/runner-api.test.ts index 747e07751..59f670cc0 100644 --- a/factory/api/src/runs/runner-api.test.ts +++ b/factory/api/src/runs/runner-api.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { handleRunControlEvent } from "../handlers/run-control.ts"; +import { appendEvent } from "../progress/append.ts"; import { accountContext, createRunsHarness, type RunsHarness } from "../testing/runs.ts"; import { createRunnerApiApp } from "./runner-api.ts"; @@ -12,7 +13,7 @@ beforeEach(async () => { h.github.installations.set(9, new Map([[101, "org-a/repo-a"]])); h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }]); await h.table.putRegistration({ accountId: "acc-1", repositoryId: 101, fullName: "org-a/repo-a", installationId: 9, registeredBy: "u", registeredAt: "t" }); - app = createRunnerApiApp(() => ({ verifier: h.tokens.verify, control: h.control, table: h.table, github: h.github, platform: h.platform })); + app = createRunnerApiApp(() => ({ verifier: h.tokens.verify, control: h.control, table: h.table, github: h.github, platform: h.platform, organizer: h.organizer })); const { sprint_run_id } = await h.control.startSprintRun(accountContext({ executionMode: "managed" }), { repositoryId: 101, issues: [1] }); const jobId = (await h.table.getRun(sprint_run_id))!.currentJobId!; const instruction = h.platform.taskOf(jobId)![1].instruction; @@ -55,6 +56,52 @@ describe("ランナー向けの API(決定 7・8、受け入れ条件 30)", }); }); +describe("Factory のオーガナイザーの経路(#2439)", () => { + const get = (path: string, token: string) => app.request(path, { headers: { Authorization: `Bearer ${token}` } }); + + it("答えの経路はそのレーンの答えと配布済みの範囲を返し、まとめた配布の経路は配布の実行のジョブだけが読める", async () => { + const answers = await get("/run-jobs/organizer/answers", run.token); + expect(answers.status).toBe(200); + expect(await answers.json()).toEqual({ answers: [], ranges: [] }); + expect((await get("/run-jobs/organizer/batch", run.token)).status).toBe(403); + + // 実行が配布の依頼を出して答えを待つと、配布の実行が起き、その token でまとめた配布を読める + await appendEvent(h.progress, { + sprint_run_id: run.sprintRunId, + idempotency_key: "run-x-r1", + type: "release.requested", + at: h.clock.now.toISOString(), + source: "runner", + job_id: run.jobId, + schema_version: 1, + payload: { request_id: "run-x-r1", lane: `run-${run.sprintRunId}`, prs: [5], issues: [1], ndf_version: "10.27.16", requested_at: h.clock.now.toISOString() }, + }); + await appendEvent(h.progress, { + sprint_run_id: run.sprintRunId, + idempotency_key: `${run.jobId}:finished`, + type: "job.finished", + at: h.clock.now.toISOString(), + source: "runner", + job_id: run.jobId, + schema_version: 1, + payload: { result: "gate", summary: "s", reason: "release_requested" }, + }); + await h.control.onTaskStateChange(run.jobId, "STOPPED"); + const release = [...h.table.runs.values()].find((r) => r.purpose === "release")!; + const instruction = h.platform.taskOf(release.currentJobId!)![1].instruction; + const token = h.platform.tokens.get(instruction.INGEST_TOKEN_SECRET_ARN)!; + const batch = await get("/run-jobs/organizer/batch", token); + expect(batch.status).toBe(200); + expect(await batch.json()).toEqual({ + seq: 1, + step: "present", + lanes: [{ lane: `run-${run.sprintRunId}`, requests: [{ id: "run-x-r1", prs: [5] }], issues: [1] }], + presentations: [], + choices: ["continue", "reject"], + }); + }); +}); + describe("停止の知らせと見回りの handler", () => { it("ECS の知らせの startedBy でジョブを引き、見回りの起動で sweep する", async () => { await handleRunControlEvent(h.control, { "detail-type": "ECS Task State Change", detail: { startedBy: run.jobId, lastStatus: "RUNNING" } }); diff --git a/factory/api/src/runs/runner-api.ts b/factory/api/src/runs/runner-api.ts index e3adda91b..587312309 100644 --- a/factory/api/src/runs/runner-api.ts +++ b/factory/api/src/runs/runner-api.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { ApiError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; +import type { OrganizerControl } from "../organizer/control.ts"; import { IngestForbiddenError, IngestUnauthorizedError, IngestUnavailableError } from "../progress/errors.ts"; import { RunJobTokenError, type RunJobTokenVerifier, type VerifiedRunJob } from "../progress/run-job-token.ts"; import type { RunControl } from "./control.ts"; @@ -15,6 +16,7 @@ export interface RunnerApiDeps { table: RunsTable; github: GitHubPort; platform: RunnerPlatform; + organizer?: OrganizerControl; } type Env = { Variables: { job: VerifiedRunJob } }; @@ -72,5 +74,30 @@ export function createRunnerApiApp(deps: () => RunnerApiDeps): Hono { return c.json({ url: url.url, expires_at: url.expiresAt }); }); + // 配布の依頼の答えと配布済みの範囲(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へ書く + app.get("/run-jobs/organizer/answers", async (c) => { + const d = deps(); + const claims = c.get("job"); + const { run } = await d.control.activeJobFor(claims.jobId, claims.sprintRunId).catch(() => { + throw new IngestForbiddenError(); + }); + if (!d.organizer) throw new IngestUnavailableError(); + return c.json(await d.organizer.answersFor(run)); + }); + + // 配布の実行のまとめた配布の中身(batch.json)。配布の実行のジョブだけが読める + app.get("/run-jobs/organizer/batch", async (c) => { + const d = deps(); + const claims = c.get("job"); + const { run, job } = await d.control.activeJobFor(claims.jobId, claims.sprintRunId).catch(() => { + throw new IngestForbiddenError(); + }); + if (run.purpose !== "release") throw new IngestForbiddenError(); + if (!d.organizer) throw new IngestUnavailableError(); + const batch = await d.organizer.batchFor(run, job.releaseStep ?? null); + if (!batch) throw new ApiError(404, "release_not_found", "まとめた配布が見つかりません"); + return c.json(batch); + }); + return app; } diff --git a/factory/api/src/runs/table.ts b/factory/api/src/runs/table.ts index bec2793fb..231906837 100644 --- a/factory/api/src/runs/table.ts +++ b/factory/api/src/runs/table.ts @@ -1,5 +1,7 @@ // 実行の表(RUNS_TABLE_NAME)の口。条件つきの書き込みを操作の単位で持ち、同時の 2 本でも不変条件が崩れない形にする +import type { OrganizerRoot, OrganizerWrite, OrgLog, OrgRequest, ReleaseAnswer, ReleaseBatch } from "../organizer/model.ts"; import type { + AwaitingReason, CreateRunGuards, CreateRunResult, CredentialRow, @@ -18,6 +20,30 @@ import type { export interface RunTransitionExtras { finishReason?: FinishReason | null; loop?: LoopWrite; + // 受けた配布の依頼の行(#2439 の I59)。無い行だけを渡す。実行の移りと同じトランザクションで書く + requests?: OrgRequest[]; + // 承認待ちの理由と、手動の実行が待つ配布の依頼(I65。awaitApproval だけが読む) + awaitingReason?: AwaitingReason | null; + releaseRequestId?: string | null; + // 取り消しの印を外す(main へマージした後に取り消しを受けた配布の実行を、止まった配布として残すとき。awaitApproval だけが読む) + clearCancel?: boolean; +} + +// 再開のジョブを作る条件(#2439)。expectAwaiting を省くと、承認待ちの理由が空のときだけ書く +export interface ResumeOptions { + from?: "awaiting_approval" | "awaiting_reset"; + loop?: LoopWrite; + expectAwaiting?: { reason: AwaitingReason; releaseRequestId?: string }; + // まとめた配布の根と配布の書き込み(止まった配布を続けるとき。状態の戻しとジョブの作成を同じトランザクションで書く) + organizer?: OrganizerWrite; +} + +// まとめた配布と配布の実行を 1 回で書く(I57)。legacySeq はリポジトリの枠の legacySeq を照らした値(無ければ null。I62) +export interface ComposeRelease { + write: OrganizerWrite & { batch: ReleaseBatch; log: OrgLog }; + run: SprintRun; + job: RunJob; + legacySeq: number | null; } export interface RunsTable { @@ -50,7 +76,7 @@ export interface RunsTable { // 終わりの状態でない実行を running にする(starting のときだけ) markRunRunning(sprintRunId: string, now: string): Promise; // 承認待ち(from が awaiting_reset ならリセット待ち)の実行に resume のジョブを足す。動くジョブが無いときだけ書く(I9) - addResumeJob(job: RunJob, now: string, opts?: { from?: "awaiting_approval" | "awaiting_reset"; loop?: LoopWrite }): Promise; + addResumeJob(job: RunJob, now: string, opts?: ResumeOptions): Promise; // 動くジョブを外し、実行を承認待ちへ移す(終わりの状態でないときだけ) awaitApproval(sprintRunId: string, jobId: string, now: string, extras?: RunTransitionExtras): Promise; // 動くジョブを外し、実行をリセット待ちへ移す(local の利用上限。#2347 の決定 7) @@ -58,16 +84,16 @@ export interface RunsTable { // リセット時刻が before 以前のリセット待ちの実行(見回りが引く) listResetRuns(before: string): Promise; // 終わりの状態へ移し、2 つの枠を 1 ずつ減らす。課題の持ち分を消し、古い版の実行なら legacyCount も減らす。 - // 既に終わりの状態なら false(I34・I47・I48) + // 配布の実行は枠を持たないため減らさない(I64)。既に終わりの状態なら false(I34・I47・I48) finishRun(sprintRunId: string, state: RunState, reason: FailureReason | null, now: string, extras?: RunTransitionExtras): Promise; getJob(jobId: string): Promise; // 終わりの状態でないジョブ(見回りが引く) listActiveJobs(): Promise; - // ジョブを書き換える。from を渡すと、今の状態がそのどれかのときだけ書く。手元のランナーが受け取ったジョブを - // 終わりの状態へ移すときは、そのランナーの枠も同じ書き込みで減らす(I50) + // ジョブを書き換える。from を渡すと、今の状態がそのどれかのときだけ書く。手元のマシンが受け取ったジョブを + // 終わりの状態へ移すときは、そのマシンの枠も同じ書き込みで減らす(I50) updateJob(jobId: string, patch: Partial, from?: RunJob["state"][]): Promise; - // local の pending のジョブを running にし、手元のランナーの枠を 1 増やす。枠が maxJobs 以上なら false(I50) + // local の pending のジョブを running にし、手元のマシンの枠を 1 増やす。枠が maxJobs 以上なら false(I50) claimLocalJob(jobId: string, runnerId: string, maxJobs: number, now: string): Promise; // ---- 開発ループ(#2347) ---- @@ -96,4 +122,24 @@ export interface RunsTable { listUnbackfilledRuns(): Promise; // 実行を古い版として legacyCount と課題の持ち分へ写す。実行が終わりの状態か legacy が空でなければ false backfillRun(run: SprintRun, issues: number[]): Promise; + + // ---- Factory のオーガナイザー(#2439) ---- + getOrganizer(accountId: string, repositoryId: number): Promise; + // 根(と、渡せばまとめた配布・記録)を条件つきで書く(I56・I67)。条件で落ちれば false + writeOrganizer(write: OrganizerWrite): Promise; + // まとめた配布・配布の実行とジョブ・根・記録を 1 回で書く(I57・I62)。条件で落ちれば false + composeRelease(input: ComposeRelease): Promise; + listOrgRequests(accountId: string, repositoryId: number): Promise; + // 答えがまだ無いときだけ書く(I58)。書いたら true + answerRequest(accountId: string, repositoryId: number, requestId: string, answer: ReleaseAnswer): Promise; + // 答えを届け終えた(見回りの索引から外す) + markDelivered(accountId: string, repositoryId: number, requestId: string, at: string): Promise; + getBatch(accountId: string, repositoryId: number, seq: number): Promise; + // 新しい順 + listBatches(accountId: string, repositoryId: number, limit: number): Promise; + appendOrgLog(log: OrgLog): Promise; + // 時刻の古い順 + listOrgLogs(accountId: string, repositoryId: number): Promise; + // 流れているまとめた配布を持つ根か、答えを届け終えていない依頼のあるリポジトリ(見回りが引く) + listOrganizerActive(): Promise<{ accountId: string; repositoryId: number }[]>; } diff --git a/factory/api/src/testing/runs.ts b/factory/api/src/testing/runs.ts index 0fd5ce501..93d295627 100644 --- a/factory/api/src/testing/runs.ts +++ b/factory/api/src/testing/runs.ts @@ -6,11 +6,14 @@ import type { GitHubPort, InstallationToken, IssueSummary, + PullByHead, + PullHead, RankingTable, ReachableRepository, RepositoryVisibility, } from "../github/port.ts"; -import { appendControlEventTo } from "../progress/append.ts"; +import { OrganizerControl } from "../organizer/control.ts"; +import { appendControlEventTo, appendReleaseAnsweredTo } from "../progress/append.ts"; import { MemoryProgressEventStore } from "../progress/memory-store.ts"; import { memoryProgressReader } from "../progress/read.ts"; import { RunControl, type RunControlConfig } from "../runs/control.ts"; @@ -78,6 +81,24 @@ export class FakeGitHub implements GitHubPort { if (!this.installations.get(installationId)?.has(repositoryId)) return null; return this.visibilities.get(repositoryId) ?? "private"; } + + // Factory のオーガナイザー(#2439)。<リポジトリの名前>#<枝> → 先頭、名前 → Pull Request + readonly heads = new Map(); + readonly pulls = new Map(); + readonly headReads: string[] = []; + + async branchHead(_installationId: number, _repositoryId: number, fullName: string, branch: string) { + this.headReads.push(`${fullName}#${branch}`); + return this.heads.get(`${fullName}#${branch}`) ?? null; + } + async listOpenPullHeads(_installationId: number, _repositoryId: number, fullName: string, base: string): Promise { + return (this.pulls.get(fullName) ?? []).filter((p) => p.state === "open" && p.base === base).map((p) => ({ head: p.head, url: p.url })); + } + async findPullByHead(_installationId: number, _repositoryId: number, fullName: string, head: string, base: string): Promise { + const found = (this.pulls.get(fullName) ?? []).filter((p) => p.head === head && p.base === base); + const pick = found.find((p) => p.state === "merged") ?? found.find((p) => p.state === "open") ?? found[0]; + return pick ? { state: pick.state, url: pick.url } : null; + } } export class FakePlatform implements RunnerPlatform { @@ -141,6 +162,7 @@ export interface RunsHarness { progress: MemoryProgressEventStore; tokens: RunJobTokenKit; control: RunControl; + organizer: OrganizerControl; clock: { now: Date }; logs: Record[]; } @@ -168,5 +190,15 @@ export async function createRunsHarness(config: RunControlConfig = TEST_CONFIG): newId: () => `id${String(++id).padStart(4, "0")}`, log: (e) => logs.push(e), }); - return { table, github, platform, progress, tokens, control, clock, logs }; + const organizer = new OrganizerControl({ + table, + github, + reader: memoryProgressReader(progress), + control, + appendAnswered: (e) => appendReleaseAnsweredTo(progress, e), + now: () => clock.now, + log: (e) => logs.push(e), + }); + control.setOrganizer(organizer); + return { table, github, platform, progress, tokens, control, organizer, clock, logs }; } diff --git a/factory/api/src/views/derive.test.ts b/factory/api/src/views/derive.test.ts new file mode 100644 index 000000000..f1d6fd9db --- /dev/null +++ b/factory/api/src/views/derive.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; +import type { ProgressEvent } from "@ndf-factory/contracts"; +import { toRecord } from "../progress/append.ts"; +import { capSteps, latestOf, type StepRecord } from "../progress/read.ts"; +import type { ProgressEventRecord } from "../progress/store.ts"; +import type { SprintRun } from "../runs/model.ts"; +import { deriveDetail, deriveRefinements, deriveStepDetail, gatesOf, listRowExtras, stepRows } from "./derive.ts"; + +const SHA = (c: string) => c.repeat(40); + +function runOf(patch: Partial = {}): SprintRun { + return { + sprintRunId: "sr-1", + accountId: "acc-1", + repositoryId: 101, + repo: "org-a/repo-a", + state: "running", + issues: [11, 12], + ndfVersion: "10.27.15", + executionMode: "byok", + modelCredentialSecretArn: null, + currentJobId: "job-1", + jobCount: 1, + cancelRequested: false, + failureReason: null, + createdBy: "user-1", + createdAt: "2026-11-04T00:00:00.000Z", + updatedAt: "2026-11-04T00:00:00.000Z", + ...patch, + }; +} + +// 事象の列を作る。秒ずつ進む時刻と連番の鍵を振る +function events(sprintRunId = "sr-1") { + const out: ProgressEventRecord[] = []; + let n = 0; + const add = (type: string, payload: Record, opts: { job?: string; at?: string; source?: string } = {}) => { + n += 1; + const at = opts.at ?? new Date(Date.UTC(2026, 10, 4, 0, 0, n)).toISOString(); + const job = opts.job ?? "job-1"; + const event = { + sprint_run_id: sprintRunId, + idempotency_key: `${job}:${n}`, + type, + at, + source: opts.source ?? "runner", + job_id: job, + schema_version: 1, + payload, + } as unknown as ProgressEvent; + const record = toRecord(event); + out.push(record); + return record; + }; + const step = (step: string, patch: Record = {}, opts: { job?: string; at?: string } = {}) => + add("step.recorded", { step, exit_code: 0, seconds: 10, summary: `${step} の要約`, ...patch }, opts) as StepRecord; + return { out, add, step }; +} + +describe("一覧の行(受け入れ条件 1〜3・I4・I5)", () => { + it("最後の phase.changed のフェーズ・最後の工程・最後の事象の時刻を足す", () => { + for (const last of ["設計", "検査", "仕上げ"]) { + const e = events(); + e.add("phase.changed", { phase: "実装", issues: [] }); + e.add("phase.changed", { phase: last, issues: [] }); + e.step("impl"); + const extras = listRowExtras(runOf(), latestOf(e.out)); + expect(extras).toEqual({ repo: "org-a/repo-a", current_phase: last, current_step: "impl", last_event_at: e.out.at(-1)!.at }); + } + }); + + it("phase.changed が 0 件なら null、5 つに無い名前はそのまま返す", () => { + expect(listRowExtras(runOf(), latestOf([]))).toEqual({ repo: "org-a/repo-a", current_phase: null, current_step: null, last_event_at: null }); + const e = events(); + e.add("phase.changed", { phase: "準備", issues: [] }); + expect(listRowExtras(runOf(), latestOf(e.out)).current_phase).toBe("準備"); + }); +}); + +describe("詳細(受け入れ条件 5・6・8・9・I6〜I10)", () => { + it("課題 #A・#B・番号なしの工程が、それぞれの欄に order_key の順で並び、2 つの課題を持つ工程は 2 つの欄に 1 回ずつ", () => { + const e = events(); + const a1 = e.step("impl", { issues: [11] }); + const b1 = e.step("impl", { issues: [12] }); + const all = e.step("check"); + const both = e.step("pr", { issues: [11, 12] }); + const a2 = e.step("merge", { issues: [11] }); + const empty = e.step("sync", { issues: [] }); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.columns.map((c) => c.issue)).toEqual([11, 12, null]); + expect(d.columns[0]!.step_keys).toEqual([a1, both, a2].map((x) => x.idempotency_key)); + expect(d.columns[1]!.step_keys).toEqual([b1, both].map((x) => x.idempotency_key)); + expect(d.columns[2]!.step_keys).toEqual([all, empty].map((x) => x.idempotency_key)); + }); + + it("欄の Pull Request は最後の pr.updated の状態と、チェックの名前ごとの最後の結論を持ち、pr.updated が無ければ unknown", () => { + const e = events(); + e.step("pr", { issues: [11], pull_request: { repo: "org-a/repo-a", number: 7 } }); + e.step("pr", { issues: [12], pull_request: { repo: "org-a/repo-a", number: 8 } }); + e.add("pr.updated", { repo: "Org-A/Repo-A", number: 7, state: "open" }, { source: "github", job: "x" }); + e.add("ci.updated", { repo: "org-a/repo-a", pr_number: 7, name: "factory-check", conclusion: "failure" }, { source: "github" }); + e.add("ci.updated", { repo: "org-a/repo-a", pr_number: 7, name: "factory-check", conclusion: "success" }, { source: "github" }); + e.add("ci.updated", { repo: "org-a/repo-a", pr_number: 7, name: "lint", conclusion: null }, { source: "github" }); + e.add("pr.updated", { repo: "org-a/repo-a", number: 7, state: "merged" }, { source: "github" }); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.columns[0]!.pull_requests).toEqual([ + { + repo: "org-a/repo-a", + number: 7, + url: "https://github.com/org-a/repo-a/pull/7", + state: "merged", + checks: [ + { name: "factory-check", conclusion: "success" }, + { name: "lint", conclusion: null }, + ], + }, + ]); + expect(d.columns[1]!.pull_requests[0]).toMatchObject({ number: 8, state: "unknown", checks: [] }); + }); + + it("承認の欄はゲート 1・ゲート 2・判断の 3 つで、到達・承認・差し戻し・判断の答えで状態が移る", () => { + const e = events(); + expect(gatesOf(e.out)).toEqual({ gate1: { state: "not_reached", at: null }, gate2: { state: "not_reached", at: null }, decision: { state: "none", at: null } }); + // 到達の前の答えは状態を変えない + e.add("gate.answered", { approval_request_id: "a0", kind: "gate1", decision: "approved", answered_by_account_id: "acc-1" }, { source: "api" }); + expect(gatesOf(e.out).gate1.state).toBe("not_reached"); + e.add("gate.reached", { kind: "gate1", pr_url: null, material: "資料" }); + expect(gatesOf(e.out).gate1.state).toBe("waiting"); + e.add("gate.answered", { approval_request_id: "a1", kind: "gate1", decision: "rejected", answered_by_account_id: "acc-1" }, { source: "api" }); + expect(gatesOf(e.out).gate1.state).toBe("rejected"); + e.add("gate.reached", { kind: "gate1", pr_url: null, material: "直した資料" }); + e.add("gate.answered", { approval_request_id: "a2", kind: "gate1", decision: "approved", answered_by_account_id: "acc-1" }, { source: "api" }); + e.add("gate.reached", { kind: "decision", pr_url: null, material: "問い" }); + const mid = gatesOf(e.out); + expect([mid.gate1.state, mid.gate2.state, mid.decision.state]).toEqual(["approved", "not_reached", "waiting"]); + e.add("gate.answered", { approval_request_id: "a3", kind: "decision", answered_by_account_id: "acc-1" }, { source: "api" }); + const after = gatesOf(e.out); + expect(Object.keys(after)).toEqual(["gate1", "gate2", "decision"]); + expect([after.gate1.state, after.decision.state]).toEqual(["approved", "answered"]); + }); + + it("仕上げの欄は振り返りと棚卸しの行を常に持ち、記録の前は null、後は終了コードと秒", () => { + const before = deriveDetail(runOf(), [], 0); + expect(before.phases.map((p) => p.name)).toEqual(["設計", "実装", "検査", "取り込み", "仕上げ"]); + expect(before.phases[4]!.required).toEqual([ + { label: "振り返り", step: "retro", record: null }, + { label: "棚卸し", step: "refine", record: null }, + ]); + expect(before.current_phase).toBeNull(); + const e = events(); + e.add("phase.changed", { phase: "仕上げ", issues: [] }); + const retro = e.step("retro", { exit_code: 0, seconds: 42 }); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.phases[4]!.required[0]!.record).toEqual({ key: retro.idempotency_key, exit_code: 0, seconds: 42 }); + expect(d.phases[4]!.required[1]!.record).toBeNull(); + expect(d.phases[4]!.step_keys).toEqual([retro.idempotency_key]); + }); + + it("phase の無い工程は同じ実行ジョブの直前の phase.changed のフェーズに入り、5 つに無い名前は後ろの欄になる(I5・I7)", () => { + const e = events(); + e.add("phase.changed", { phase: "実装", issues: [11] }, { job: "job-1" }); + e.add("phase.changed", { phase: "準備", issues: [] }, { job: "job-2" }); + const own = e.step("impl", {}, { job: "job-1" }); + const tagged = e.step("check", { phase: "検査" }, { job: "job-2" }); + const other = e.step("prep", {}, { job: "job-2" }); + const d = deriveDetail(runOf(), e.out, 0); + const byKey = new Map(d.steps.map((s) => [s.key, s.phase])); + expect([byKey.get(own.idempotency_key), byKey.get(tagged.idempotency_key), byKey.get(other.idempotency_key)]).toEqual(["実装", "検査", "準備"]); + expect(d.phases.map((p) => p.name)).toEqual(["設計", "実装", "検査", "取り込み", "仕上げ", "準備"]); + expect(d.current_phase).toBe("準備"); + }); + + it("工程の費用は同じ job_id・step・at の usage.recorded の和で、別の at は足さず、無ければ null(I8)", () => { + const e = events(); + const at = "2026-11-04T01:00:00.000Z"; + const s = e.step("impl", {}, { at }); + e.add("usage.recorded", { model: "opus", input_tokens: 10, output_tokens: 2, cost_usd: 0.5, step: "impl" }, { at }); + e.add("usage.recorded", { model: "haiku", input_tokens: 5, output_tokens: 1, cost_usd: 0.25, step: "impl" }, { at }); + e.add("usage.recorded", { model: "opus", input_tokens: 1, output_tokens: 1, cost_usd: 9, step: "impl" }, { at: "2026-11-04T02:00:00.000Z" }); + e.add("usage.recorded", { model: "opus", input_tokens: 1, output_tokens: 1, cost_usd: 1, step: "conductor" }); + const lonely = e.step("pr"); + const d = deriveDetail(runOf(), e.out, 0); + const cost = new Map(d.steps.map((r) => [r.key, r.cost_usd])); + expect(cost.get(s.idempotency_key)).toBe(0.75); + expect(cost.get(lonely.idempotency_key)).toBeNull(); + expect(d.usage).toEqual({ input_tokens: 17, output_tokens: 5, cost_usd: 10.75 }); + }); + + it("足す前の形の step.recorded だけの列でも詳細を導き、has_details は偽", () => { + const e = events(); + e.step("impl"); + const d = deriveDetail(runOf(), e.out, 0); + expect(d.steps[0]).toMatchObject({ phase: null, issues: [], has_details: false, cost_usd: null, pull_request: null }); + expect(d.columns.at(-1)!.step_keys).toHaveLength(1); + }); +}); + +describe("工程の中身(受け入れ条件 7)", () => { + it("触ったファイルのリンク・差分のリンク・決めたこと・出力を返し、Pull Request があれば差分はその files", () => { + const e = events(); + const range = { from: SHA("a"), to: SHA("b") }; + const s = e.step("impl", { + files: [{ path: "factory/a b.ts", added: 3, deleted: 1 }], + commit_range: range, + decisions: [{ question: "どちら", answer: "A", by: "ai" }], + output: { kind: "report", text: "## 作業の報告", truncated: false }, + }); + const { events: stripped } = capSteps(e.out, 2000); + const row = stepRows(stripped)[0]!; + expect(row.has_details).toBe(true); + const detail = deriveStepDetail(runOf(), row, s); + expect(detail.files).toEqual([{ path: "factory/a b.ts", added: 3, deleted: 1, url: `https://github.com/org-a/repo-a/blob/${SHA("b")}/factory/a%20b.ts` }]); + expect(detail.diff_url).toBe(`https://github.com/org-a/repo-a/compare/${SHA("a")}...${SHA("b")}`); + expect(detail.decisions).toEqual([{ question: "どちら", answer: "A", by: "ai" }]); + expect(detail.output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); + const withPr = e.step("pr", { pull_request: { repo: "org-a/repo-a", number: 7 } }); + expect(deriveStepDetail(runOf(), stepRows([withPr])[0]!, withPr).diff_url).toBe("https://github.com/org-a/repo-a/pull/7/files"); + }); + + it("足した項目を持たない工程は、それぞれ null(空の配列にしない)", () => { + const e = events(); + const s = e.step("impl"); + const detail = deriveStepDetail(runOf(), stepRows([s])[0]!, s); + expect([detail.files, detail.files_omitted, detail.commit_range, detail.decisions, detail.output, detail.diff_url]).toEqual([null, null, null, null, null, null]); + }); +}); + +describe("詳細の上限(I11)", () => { + it("step.recorded 2,001 件では最も古い 1 件を省き、ほかの種類はすべて残し、ゲートと使用量は全件から導いた値と同じ", () => { + const e = events(); + e.add("gate.reached", { kind: "gate1", pr_url: null, material: "x" }); + e.add("usage.recorded", { model: "opus", input_tokens: 1, output_tokens: 1, cost_usd: 1, step: "conductor" }); + const first = e.step("impl"); + for (let i = 0; i < 2000; i += 1) e.step("impl"); + const capped = capSteps(e.out, 2000); + expect(capped.omittedSteps).toBe(1); + expect(capped.events.some((x) => x.idempotency_key === first.idempotency_key)).toBe(false); + const d = deriveDetail(runOf(), capped.events, capped.omittedSteps); + const whole = deriveDetail(runOf(), e.out, 0); + expect(d.omitted_steps).toBe(1); + expect(d.steps).toHaveLength(2000); + expect(d.gates).toEqual(whole.gates); + expect(d.usage).toEqual(whole.usage); + }); +}); + +describe("棚卸しの記録(受け入れ条件 15)", () => { + it("実行をまたいで at の新しい順に並べる", () => { + const a = events("sr-a"); + const old = a.step("refine", {}, { at: "2026-11-01T00:00:00.000Z" }); + const b = events("sr-b"); + const recent = b.step("refine", { pull_request: { repo: "org-a/repo-a", number: 3 } }, { at: "2026-11-03T00:00:00.000Z" }); + const list = deriveRefinements([ + { sprintRunId: "sr-a", steps: [old] }, + { sprintRunId: "sr-b", steps: [recent] }, + ]); + expect(list.map((r) => r.sprint_run_id)).toEqual(["sr-b", "sr-a"]); + expect(list[0]!.pull_request).toEqual({ repo: "org-a/repo-a", number: 3, url: "https://github.com/org-a/repo-a/pull/3" }); + }); +}); diff --git a/factory/api/src/views/derive.ts b/factory/api/src/views/derive.ts new file mode 100644 index 000000000..ad998ccbe --- /dev/null +++ b/factory/api/src/views/derive.ts @@ -0,0 +1,320 @@ +// スプリントの一覧・詳細・工程の中身・棚卸しの記録の派生(#2172)。進捗の事象の列とスプリントの実行の行から作る純粋な関数で、 +// 時刻も外の世界も読まず、どちらの集約も書き換えない。リンクは GitHub を呼ばずに文字列で作る(I13) +import { CLOSING_STEPS, PHASE_NAMES } from "@ndf-factory/contracts/phases"; +import type { StepDetailBody } from "@ndf-factory/contracts/views"; +import type { CiUpdatedPayload, GateAnsweredPayload, GateReachedPayload, PrUpdatedPayload, StepRecordedPayload, UsageRecordedPayload } from "@ndf-factory/contracts"; +import type { RunLatest, StepRecord } from "../progress/read.ts"; +import type { ProgressEventRecord } from "../progress/store.ts"; +import type { SprintRun } from "../runs/model.ts"; + +// 詳細で読む step.recorded の上限(過去の 11 スプリントの最大 233 件の約 8.6 倍。決定 9) +export const DETAIL_STEP_LIMIT = 2000; + +const GITHUB = "https://github.com"; + +export interface PullRequestLink { + repo: string; + number: number; + url: string; +} + +export interface StepRow { + key: string; + at: string; + job_id: string | null; + step: string; + // payload.phase、無ければ同じ実行ジョブの直前の phase.changed の phase。どちらも無ければ null(I7) + phase: string | null; + issues: number[]; + exit_code: number; + seconds: number; + // 同じ job_id・step・at の usage.recorded の cost_usd の和。無ければ null(I8) + cost_usd: number | null; + summary: string; + pull_request: PullRequestLink | null; + // 工程の中身の本文(触ったファイル・コミットの範囲・決めたこと・出力)を持つか + has_details: boolean; +} + +export interface RequiredStep { + label: string; + step: string; + record: { key: string; exit_code: number; seconds: number } | null; +} + +export interface PhaseView { + name: string; + step_keys: string[]; + required: RequiredStep[]; +} + +export interface PullRequestView extends PullRequestLink { + state: "open" | "closed" | "merged" | "unknown"; + checks: { name: string; conclusion: string | null }[]; +} + +export interface IssueColumn { + // null はスプリント全体の欄 + issue: number | null; + step_keys: string[]; + pull_requests: PullRequestView[]; +} + +export type GateState = "not_reached" | "waiting" | "approved" | "rejected"; +export type DecisionState = "none" | "waiting" | "answered"; + +export interface GatesView { + gate1: { state: GateState; at: string | null }; + gate2: { state: GateState; at: string | null }; + decision: { state: DecisionState; at: string | null }; +} + +export interface SprintRunSummaryView { + sprint_run_id: string; + repository_id: number; + issues: number[]; + state: string; + failure_reason: string | null; + created_at: string; +} + +export interface SprintRunDetail extends SprintRunSummaryView { + repo: string; + ndf_version: string; + execution_mode: string; + job_count: number; + current_phase: string | null; + phases: PhaseView[]; + columns: IssueColumn[]; + steps: StepRow[]; + gates: GatesView; + delegated_decisions: number; + usage: { input_tokens: number; output_tokens: number; cost_usd: number }; + omitted_steps: number; +} + +export type StepDetail = StepRow & StepDetailBody; + +export interface RefinementRecord { + sprint_run_id: string; + key: string; + at: string; + exit_code: number; + summary: string; + pull_request: PullRequestLink | null; +} + +type Rec = ProgressEventRecord & { body_fields?: string[] }; + +const round6 = (n: number) => Math.round(n * 1e6) / 1e6; +const prKey = (repo: string, number: number) => `${repo.toLowerCase()}#${number}`; +const prLink = (ref: { repo: string; number: number } | undefined): PullRequestLink | null => + ref ? { repo: ref.repo, number: ref.number, url: `${GITHUB}/${ref.repo}/pull/${ref.number}` } : null; + +const isStep = (e: Rec): e is StepRecord & Rec => e.type === "step.recorded"; +const BODY_FIELDS = ["files", "commit_range", "decisions", "output"] as const; +const payloadOf = (e: ProgressEventRecord) => e.payload as unknown as T; + +export function summaryOf(run: SprintRun): SprintRunSummaryView { + return { + sprint_run_id: run.sprintRunId, + repository_id: run.repositoryId, + issues: run.issues, + state: run.state, + failure_reason: run.failureReason, + created_at: run.createdAt, + }; +} + +// 一覧の行に足す 4 項目(I4)。phase.changed が 0 件なら current_phase は null(画面は「始まる前」) +export function listRowExtras(run: Pick, latest: RunLatest) { + return { + repo: run.repo, + current_phase: latest.phase ? payloadOf<{ phase: string }>(latest.phase).phase : null, + current_step: latest.step ? latest.step.payload.step : null, + last_event_at: latest.last ? latest.last.at : null, + }; +} + +// 工程の記録の行(order_key の順)。events は order_key の順の列 +export function stepRows(events: Rec[]): StepRow[] { + const cost = new Map(); + for (const e of events) { + if (e.type !== "usage.recorded") continue; + const u = payloadOf(e); + const k = `${e.job_id}\n${u.step}\n${e.at}`; + cost.set(k, (cost.get(k) ?? 0) + u.cost_usd); + } + const phaseOfJob = new Map(); + const rows: StepRow[] = []; + for (const e of events) { + if (e.type === "phase.changed") phaseOfJob.set(e.job_id, payloadOf<{ phase: string }>(e).phase); + if (!isStep(e)) continue; + const p: StepRecordedPayload = e.payload; + const c = cost.get(`${e.job_id}\n${p.step}\n${e.at}`); + rows.push({ + key: e.idempotency_key, + at: e.at, + job_id: e.job_id, + step: p.step, + phase: p.phase ?? phaseOfJob.get(e.job_id) ?? null, + issues: p.issues ?? [], + exit_code: p.exit_code, + seconds: p.seconds, + cost_usd: c === undefined ? null : round6(c), + summary: p.summary, + pull_request: prLink(p.pull_request), + // 読み出しの口は本文を外して持っていた項目の名前を body_fields に残す。外す前の列(テスト)は本文そのものを見る + has_details: e.body_fields !== undefined || BODY_FIELDS.some((k) => p[k] !== undefined), + }); + } + return rows; +} + +// 5 つの名前の順に 5 つ、その後に現れた順に 5 つに無い名前。仕上げは振り返りと棚卸しの行を常に持つ(I10) +function phasesOf(rows: StepRow[], events: Rec[]): PhaseView[] { + const others: string[] = []; + const seen = (name: string | null) => { + if (name !== null && !(PHASE_NAMES as readonly string[]).includes(name) && !others.includes(name)) others.push(name); + }; + for (const e of events) if (e.type === "phase.changed") seen(payloadOf<{ phase: string }>(e).phase); + for (const r of rows) seen(r.phase); + return [...PHASE_NAMES, ...others].map((name) => ({ + name, + step_keys: rows.filter((r) => r.phase === name).map((r) => r.key), + required: + name === "仕上げ" + ? CLOSING_STEPS.map(({ label, step }) => { + const last = rows.filter((r) => r.step === step).at(-1); + return { label, step, record: last ? { key: last.key, exit_code: last.exit_code, seconds: last.seconds } : null }; + }) + : [], + })); +} + +// 課題の欄。実行の課題の順に、続いて実行に無い課題を現れた順に、最後にスプリント全体の欄(I6) +function columnsOf(run: SprintRun, rows: StepRow[], events: Rec[]): IssueColumn[] { + const issues = [...run.issues]; + for (const r of rows) for (const n of r.issues) if (!issues.includes(n)) issues.push(n); + const prState = new Map(); + const checks = new Map>(); + for (const e of events) { + if (e.type === "pr.updated") { + const p = payloadOf(e); + prState.set(prKey(p.repo, p.number), p.state); + } + if (e.type === "ci.updated") { + const p = payloadOf(e); + const k = prKey(p.repo, p.pr_number); + if (!checks.has(k)) checks.set(k, new Map()); + checks.get(k)!.set(p.name, p.conclusion); + } + } + const column = (issue: number | null): IssueColumn => { + const own = rows.filter((r) => (issue === null ? r.issues.length === 0 : r.issues.includes(issue))); + const prs = new Map(); + for (const r of own) { + if (!r.pull_request) continue; + const k = prKey(r.pull_request.repo, r.pull_request.number); + if (prs.has(k)) continue; + prs.set(k, { + ...r.pull_request, + state: prState.get(k) ?? "unknown", + checks: [...(checks.get(k) ?? new Map()).entries()].map(([name, conclusion]) => ({ name, conclusion })), + }); + } + return { issue, step_keys: own.map((r) => r.key), pull_requests: [...prs.values()] }; + }; + return [...issues.map(column), column(null)]; +} + +// 承認の欄(I9)。その種類の最後の gate.reached と、それより後の同じ種類の gate.answered から決める +export function gatesOf(events: Rec[]): GatesView { + const gates: GatesView = { + gate1: { state: "not_reached", at: null }, + gate2: { state: "not_reached", at: null }, + decision: { state: "none", at: null }, + }; + for (const e of events) { + if (e.type === "gate.reached") { + const { kind } = payloadOf(e); + gates[kind] = { state: "waiting", at: e.at }; + } + if (e.type === "gate.answered") { + const p = payloadOf(e); + if (gates[p.kind].state !== "waiting") continue; + if (p.kind === "decision") gates.decision = { state: "answered", at: e.at }; + else gates[p.kind] = { state: p.decision === "rejected" ? "rejected" : "approved", at: e.at }; + } + } + return gates; +} + +function usageOf(events: Rec[]) { + let input = 0; + let output = 0; + let cost = 0; + for (const e of events) { + if (e.type !== "usage.recorded") continue; + const u = payloadOf(e); + input += u.input_tokens; + output += u.output_tokens; + cost += u.cost_usd; + } + return { input_tokens: input, output_tokens: output, cost_usd: round6(cost) }; +} + +// スプリントの詳細。events は order_key の順で、本文を外したもの +export function deriveDetail(run: SprintRun, events: Rec[], omittedSteps: number): SprintRunDetail { + const rows = stepRows(events); + const lastPhase = events.filter((e) => e.type === "phase.changed").at(-1); + return { + ...summaryOf(run), + repo: run.repo, + ndf_version: run.ndfVersion, + execution_mode: run.executionMode, + job_count: run.jobCount, + current_phase: lastPhase ? payloadOf<{ phase: string }>(lastPhase).phase : null, + phases: phasesOf(rows, events), + columns: columnsOf(run, rows, events), + steps: rows, + gates: gatesOf(events), + delegated_decisions: events.filter((e) => e.type === "decision.delegated").length, + usage: usageOf(events), + omitted_steps: omittedSteps, + }; +} + +const blobUrl = (repo: string, sha: string, path: string) => `${GITHUB}/${repo}/blob/${sha}/${path.split("/").map(encodeURIComponent).join("/")}`; + +// 工程の中身。row は同じ工程の行(events から導いたもの)、record は本文を含む step.recorded +export function deriveStepDetail(run: Pick, row: StepRow, record: StepRecord): StepDetail { + const p = record.payload; + const range = p.commit_range ?? null; + const diff = row.pull_request ? `${row.pull_request.url}/files` : range ? `${GITHUB}/${run.repo}/compare/${range.from}...${range.to}` : null; + return { + ...row, + files: p.files ? p.files.map((f) => ({ ...f, url: range ? blobUrl(run.repo, range.to, f.path) : null })) : null, + files_omitted: p.files_omitted ?? null, + commit_range: range, + decisions: p.decisions ?? null, + output: p.output ?? null, + diff_url: diff, + }; +} + +// そのリポジトリの実行の棚卸しの工程(step が refine)を at の新しい順に +export function deriveRefinements(perRun: { sprintRunId: string; steps: StepRecord[] }[]): RefinementRecord[] { + return perRun + .flatMap(({ sprintRunId, steps }) => + steps.map((e) => ({ + sprint_run_id: sprintRunId, + key: e.idempotency_key, + at: e.at, + exit_code: e.payload.exit_code, + summary: e.payload.summary, + pull_request: prLink(e.payload.pull_request), + })), + ) + .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); +} diff --git a/factory/contracts/openapi/accounts.yaml b/factory/contracts/openapi/accounts.yaml index da7b28aa5..45624838a 100644 --- a/factory/contracts/openapi/accounts.yaml +++ b/factory/contracts/openapi/accounts.yaml @@ -70,7 +70,7 @@ paths: get: tags: [accounts] operationId: getAccount - summary: アカウントのプラン・実行の形態・使える量・席 + summary: アカウントの料金プラン・実行の形態・使える量・席 description: 個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。 responses: "200": @@ -271,7 +271,7 @@ components: enum: [standard, premium] ExecutionMode: type: string - description: local(手元のランナー)・byok(キーの持ち込み)・managed(サービスの Bedrock) + description: local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock) enum: [local, byok, managed] Idp: type: string diff --git a/factory/contracts/openapi/organizer.yaml b/factory/contracts/openapi/organizer.yaml new file mode 100644 index 000000000..4c9eae1e7 --- /dev/null +++ b/factory/contracts/openapi/organizer.yaml @@ -0,0 +1,267 @@ +openapi: 3.1.0 +info: + title: Neural Development Factory のオーガナイザーの API + 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: [] +paths: + /accounts/{accountId}/repositories/{repositoryId}/approval-requests: + get: + tags: [organizer] + operationId: listRepositoryApprovalRequests + summary: リポジトリの承認と問いの一覧 + description: >- + 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 + 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。 + parameters: + - $ref: "#/components/parameters/AccountId" + - $ref: "#/components/parameters/RepositoryId" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/ApprovalRequestList" + "403": + $ref: "#/components/responses/E403" + "404": + $ref: "#/components/responses/E404" + /accounts/{accountId}/repositories/{repositoryId}/releases: + get: + tags: [organizer] + operationId: listRepositoryReleases + summary: まとめた配布の履歴 + description: 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。 + parameters: + - $ref: "#/components/parameters/AccountId" + - $ref: "#/components/parameters/RepositoryId" + - name: limit + in: query + required: false + schema: { type: integer, minimum: 1, maximum: 100 } + responses: + "200": + 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: + 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 } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveRequest" + responses: + "202": + description: 受けた + content: + 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" +components: + securitySchemes: + cognito: + 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 + responses: + E400: + description: 要求の形が違う + 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" + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: { type: string } + message: { type: string } + 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・古い版の実行の数) } + ReleaseLane: + type: object + required: [lane, requested, prs] + properties: + lane: { type: string } + requested: { type: boolean } + prs: { type: array, items: { type: integer } } + ReleaseSummary: + type: object + required: [seq, k, sha, lanes, added_prs, choices] + properties: + seq: { type: integer } + 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] } } + ApprovalRequestItem: + type: object + required: [kind, created_at] + properties: + kind: { type: string, enum: [gate1, gate2, decision, loop_pending, release_stalled] } + sprint_run_id: { type: string } + loop_id: { type: string } + approval_request_id: { type: string } + 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] + properties: + organizer: { $ref: "#/components/schemas/OrganizerSummary" } + items: { type: array, items: { $ref: "#/components/schemas/ApprovalRequestItem" } } + Presentation: + type: object + required: [k, sha, version, prs, lanes, added_prs, at] + properties: + k: { type: integer } + sha: { type: string } + version: { type: string } + prs: { type: array, items: { type: integer } } + lanes: + type: array + items: + type: object + required: [lane, requested] + 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"] } + 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] } + detail: { type: object } + ReleaseBatch: + type: object + required: [seq, state, release_run_id, presentations, confirmed_k, result, log] + properties: + seq: { type: integer } + 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"] } + 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] } diff --git a/factory/contracts/openapi/runs.yaml b/factory/contracts/openapi/runs.yaml index fe4d73b77..9aa7ff265 100644 --- a/factory/contracts/openapi/runs.yaml +++ b/factory/contracts/openapi/runs.yaml @@ -293,7 +293,7 @@ paths: tags: [sprint-runs] operationId: listSprintRuns summary: スプリントの実行の一覧 - description: 新しい順に 50 件。repository_id で絞れる。 + description: 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。 parameters: - name: accountId in: path @@ -315,12 +315,115 @@ paths: $ref: "#/components/schemas/SprintRunList" "403": $ref: "#/components/responses/E403" + "503": + $ref: "#/components/responses/E503" + /accounts/{accountId}/sprint-runs/{sprintRunId}: + get: + tags: [sprint-runs] + operationId: getSprintRunDetail + summary: スプリントの詳細 + description: >- + フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 + 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の経路で 1 件ずつ読む。 + step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 + 無い実行・アカウントに属さない実行も、他人のアカウントと同じ本文の 403。 + 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/SprintRunDetail" + "403": + $ref: "#/components/responses/E403" + "503": + $ref: "#/components/responses/E503" + /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}: + get: + tags: [sprint-runs] + operationId: getStepDetail + summary: 工程の中身 + description: >- + 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 + stepKey は事象の idempotency_key(URL の符号化をする)。記録の無い項目は null。 + 無い工程・別の実行の工程も、他人のアカウントと同じ本文の 403。 + parameters: + - name: accountId + in: path + required: true + schema: + type: string + - name: sprintRunId + in: path + required: true + schema: + type: string + - name: stepKey + in: path + required: true + schema: + type: string + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/StepDetail" + "403": + $ref: "#/components/responses/E403" + "503": + $ref: "#/components/responses/E503" + /accounts/{accountId}/repositories/{repositoryId}/backlog: + get: + tags: [repositories] + operationId: getBacklog + summary: バックログ + description: >- + リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 + 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリも、他人のアカウントと同じ本文の 403。 + parameters: + - name: accountId + in: path + required: true + schema: + type: string + - name: repositoryId + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + "200": + description: 成功 + content: + 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: post: tags: [sprint-runs] operationId: cancelSprintRun summary: スプリントの実行を取り消す - description: 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。 + description: 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。 parameters: - name: accountId in: path @@ -431,7 +534,7 @@ paths: tags: [repositories] operationId: updateRepository summary: リポジトリの同時の実行の数を宣言する - description: プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。 + description: 料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。 parameters: - name: accountId in: path @@ -487,6 +590,50 @@ paths: $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" components: securitySchemes: cognito: @@ -680,6 +827,235 @@ components: 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: 最後の事象の時刻 } + PullRequestLink: + type: object + required: [repo, number, url] + 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"] } + step: { type: string } + 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 } + summary: { type: string } + pull_request: + oneOf: + - $ref: "#/components/schemas/PullRequestLink" + - type: "null" + has_details: { type: boolean, description: 工程の中身の本文を持つか } + RequiredStep: + type: object + required: [label, step, record] + properties: + label: { type: string } + step: { type: string } + record: + oneOf: + - type: object + required: [key, exit_code, seconds] + 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 } + SprintRunDetail: + type: object + required: + - sprint_run_id + - repository_id + - issues + - state + - failure_reason + - created_at + - repo + - ndf_version + - execution_mode + - job_count + - current_phase + - phases + - columns + - steps + - gates + - delegated_decisions + - usage + - omitted_steps + 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 } + ndf_version: { type: string } + execution_mode: { type: string, enum: [local, byok, managed] } + job_count: { type: integer } + current_phase: { type: ["string", "null"] } + phases: + type: array + description: 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ + items: + type: object + required: [name, step_keys, required] + properties: + name: { type: string } + step_keys: { type: array, items: { type: string } } + required: { type: array, items: { $ref: "#/components/schemas/RequiredStep" } } + columns: + type: array + description: 実行の課題の順に課題の欄、最後にスプリント全体の欄(issue が null) + items: + type: object + required: [issue, step_keys, pull_requests] + properties: + issue: { type: ["integer", "null"] } + step_keys: { type: array, items: { type: string } } + pull_requests: + type: array + items: + type: object + required: [repo, number, url, state, checks] + properties: + repo: { type: string } + number: { type: integer } + url: { type: string, format: uri } + state: { type: string, enum: [open, closed, merged, unknown] } + checks: + type: array + items: + type: object + required: [name, conclusion] + properties: + name: { type: string } + conclusion: { type: ["string", "null"], description: null は実行中 } + steps: { type: array, items: { $ref: "#/components/schemas/StepRow" } } + gates: + type: object + required: [gate1, gate2, decision] + properties: + gate1: { $ref: "#/components/schemas/GateView" } + gate2: { $ref: "#/components/schemas/GateView" } + decision: + type: object + required: [state, at] + properties: + state: { type: string, enum: [none, waiting, answered] } + 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 } + omitted_steps: { type: integer, description: 上限で省いた古い工程の数 } + StepDetail: + allOf: + - $ref: "#/components/schemas/StepRow" + - type: object + required: [files, files_omitted, commit_range, decisions, output, diff_url] + properties: + files: + 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"] } + commit_range: + oneOf: + - type: object + required: [from, to] + properties: + from: { type: string } + to: { type: string } + - type: "null" + decisions: + type: ["array", "null"] + items: + type: object + required: [question, answer, by] + properties: + question: { type: string } + answer: { type: string } + by: { type: string, enum: [ai, user] } + output: + oneOf: + - type: object + required: [kind, text, truncated] + properties: + kind: { type: string, enum: [report, log] } + text: { type: string } + truncated: { type: boolean } + - type: "null" + diff_url: { type: ["string", "null"], format: uri } + Backlog: + type: object + required: [repository_id, repo, ranking, refinements] + properties: + repository_id: { type: integer } + repo: { type: string } + ranking: + description: null は順位の表が無い + oneOf: + - type: object + required: [milestone, rows] + properties: + milestone: { type: string } + rows: + type: array + items: + type: object + required: [rank, number, title] + properties: + rank: { type: integer, minimum: 1 } + number: { type: integer } + title: { type: string } + - type: "null" + refinements: + type: array + description: 棚卸しの工程(step が refine)の記録を新しい順に + items: + type: object + required: [sprint_run_id, key, at, exit_code, summary, pull_request] + properties: + sprint_run_id: { type: string } + key: { type: string } + at: { type: string, format: date-time } + exit_code: { type: integer } + summary: { type: string } + pull_request: + oneOf: + - $ref: "#/components/schemas/PullRequestLink" + - type: "null" CancelResult: type: object required: [state] @@ -714,3 +1090,51 @@ components: 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"] } + 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"] } + prs: { type: array, items: { type: integer } } + lanes: { type: array, items: { type: string } } + OrganizerAnswers: + type: object + required: [answers, ranges] + properties: + answers: { type: array, items: { $ref: "#/components/schemas/OrganizerAnswer" } } + ranges: { type: array, items: { $ref: "#/components/schemas/OrganizerRange" } } + OrganizerBatchLane: + type: object + required: [lane, requests, issues] + properties: + lane: { type: string } + requests: + type: array + items: + type: object + required: [id, prs] + properties: + id: { type: string } + prs: { type: array, items: { type: integer } } + issues: { type: array, items: { type: integer } } + OrganizerBatch: + type: object + required: [seq, step, lanes, presentations, choices] + properties: + seq: { type: integer } + step: { type: string, enum: [present, represent, "confirm:1", "confirm:2", continue] } + lanes: { type: array, items: { $ref: "#/components/schemas/OrganizerBatchLane" } } + presentations: { type: array, items: { type: object } } + choices: { type: array, items: { type: string, enum: [continue, reject] } } diff --git a/factory/contracts/package.json b/factory/contracts/package.json index d4f052f41..99b1d6f47 100644 --- a/factory/contracts/package.json +++ b/factory/contracts/package.json @@ -3,6 +3,11 @@ "private": true, "type": "module", "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./phases": "./src/phases.ts", + "./views": "./src/views.ts" + }, "scripts": { "generate": "node scripts/generate.ts", "typecheck": "tsc --noEmit", diff --git a/factory/contracts/schemas/local-runner-declaration.schema.json b/factory/contracts/schemas/local-runner-declaration.schema.json index c1759a7e9..97984be08 100644 --- a/factory/contracts/schemas/local-runner-declaration.schema.json +++ b/factory/contracts/schemas/local-runner-declaration.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/devbasex/ai-plugins/blob/develop/factory/contracts/schemas/local-runner-declaration.schema.json", "title": "LocalRunnerDeclaration", - "description": "手元のランナーが登録と心拍で申告する、同時に動かす実行ジョブの数(#2347)。登録と心拍の本文はこの形を含む", + "description": "手元のマシンが登録と心拍で申告する、同時に動かす実行ジョブの数(#2347)。登録と心拍の本文はこの形を含む", "type": "object", "additionalProperties": false, "required": ["runner_id"], diff --git a/factory/contracts/schemas/progress-event.schema.json b/factory/contracts/schemas/progress-event.schema.json index 2d595114a..88a7ef854 100644 --- a/factory/contracts/schemas/progress-event.schema.json +++ b/factory/contracts/schemas/progress-event.schema.json @@ -19,7 +19,12 @@ { "$ref": "#/$defs/IssueRequestResolvedEvent" }, { "$ref": "#/$defs/SectionStartedEvent" }, { "$ref": "#/$defs/LoopLapClosedEvent" }, - { "$ref": "#/$defs/LoopStoppedEvent" } + { "$ref": "#/$defs/LoopStoppedEvent" }, + { "$ref": "#/$defs/ReleaseRequestedEvent" }, + { "$ref": "#/$defs/ReleasePresentedEvent" }, + { "$ref": "#/$defs/ReleaseProgressEvent" }, + { "$ref": "#/$defs/ReleaseSettledEvent" }, + { "$ref": "#/$defs/ReleaseAnsweredEvent" } ], "$defs": { "Identifier": { @@ -80,7 +85,7 @@ "required": ["ndf_version", "start"], "properties": { "ndf_version": { "type": "string", "minLength": 1 }, - "start": { "type": "string", "enum": ["new", "resume", "loop"] } + "start": { "type": "string", "enum": ["new", "resume", "loop", "release"] } } }, "PhaseChangedPayload": { @@ -88,7 +93,7 @@ "additionalProperties": false, "required": ["phase", "issues"], "properties": { - "phase": { "description": "フェーズの名前。集合は #2172 が決めるまで列挙にしない", "type": "string", "minLength": 1 }, + "phase": { "description": "フェーズの名前。5 つの名前(設計・実装・検査・取り込み・仕上げ)は src/phases.ts が持ち、ここでは列挙で縛らない", "type": "string", "minLength": 1 }, "issues": { "type": "array", "items": { "type": "integer", "minimum": 1 } } } }, @@ -111,7 +116,74 @@ "exit_code": { "type": "integer" }, "seconds": { "type": "number", "minimum": 0 }, "summary": { "type": "string" }, - "pull_request": { "$ref": "#/$defs/PullRequestRef" } + "pull_request": { "$ref": "#/$defs/PullRequestRef" }, + "phase": { + "description": "その工程が属するフェーズの名前。省いたときは同じ実行ジョブの直前の phase.changed から導く", + "type": "string", + "minLength": 1 + }, + "issues": { + "description": "その工程が受け持つ課題。省いたときも空のときもスプリント全体の工程", + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "maxItems": 50 + }, + "files": { + "description": "その工程の時間の窓のコミットが触ったファイル。省いたときは記録なし、空の配列は触らなかった", + "type": "array", + "items": { "$ref": "#/$defs/StepFile" }, + "maxItems": 100 + }, + "files_omitted": { "description": "上限で files から外した数", "type": "integer", "minimum": 0 }, + "commit_range": { "$ref": "#/$defs/CommitRange" }, + "decisions": { + "description": "その工程の間に決めたこと。省いたときは記録なし、空の配列は決めたことが無かった", + "type": "array", + "items": { "$ref": "#/$defs/StepDecision" }, + "maxItems": 20 + }, + "output": { "$ref": "#/$defs/StepOutput" } + } + }, + "StepFile": { + "type": "object", + "additionalProperties": false, + "required": ["path", "added", "deleted"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 512 }, + "added": { "description": "null はバイナリ(行数が無い)", "type": ["integer", "null"], "minimum": 0 }, + "deleted": { "description": "null はバイナリ(行数が無い)", "type": ["integer", "null"], "minimum": 0 } + } + }, + "CommitRange": { + "description": "時間の窓のコミットの最初の親と最後のコミット", + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "to": { "type": "string", "pattern": "^[0-9a-f]{40}$" } + } + }, + "StepDecision": { + "type": "object", + "additionalProperties": false, + "required": ["question", "answer", "by"], + "properties": { + "question": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "answer": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "by": { "type": "string", "enum": ["ai", "user"] } + } + }, + "StepOutput": { + "description": "作業の報告(report)かログ(log)。text は UTF-8 で 32,768 バイト以下で、truncated は上限で切ったこと", + "type": "object", + "additionalProperties": false, + "required": ["kind", "text", "truncated"], + "properties": { + "kind": { "type": "string", "enum": ["report", "log"] }, + "text": { "type": "string", "maxLength": 32768 }, + "truncated": { "type": "boolean" } } }, "AttentionPayload": { @@ -131,7 +203,14 @@ "kind": { "$ref": "#/$defs/GateKind" }, "pr_url": { "type": ["string", "null"], "format": "uri" }, "material": { "description": "承認資料の本文", "type": "string" }, - "loop": { "$ref": "#/$defs/LoopRef" } + "loop": { "$ref": "#/$defs/LoopRef" }, + "choices": { + "description": "答えの選択肢の制限。配布の実行が main へのマージの後に判断で止まったときは continue だけを載せる(#2439 の I63)", + "type": "array", + "items": { "type": "string", "enum": ["continue", "reject"] }, + "minItems": 1, + "uniqueItems": true + } } }, "LoopRef": { @@ -249,9 +328,9 @@ "result": { "type": "string", "enum": ["gate", "stopped", "done", "failed"] }, "summary": { "type": "string" }, "reason": { - "description": "stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限", + "description": "stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限。release_requested は gate の理由で、手動の実行が配布の依頼を出して答えを待つ(#2439)", "type": "string", - "enum": ["section_limit", "usage_limit"] + "enum": ["section_limit", "usage_limit", "release_requested"] }, "resets_at": { "description": "usage_limit のときだけ。利用上限のリセット時刻(読めなければ null)", @@ -336,6 +415,107 @@ "then": { "required": ["issue"], "not": { "required": ["reason"] } }, "else": { "required": ["reason"], "not": { "required": ["issue"] } } }, + "ReleaseLaneName": { + "description": "並列のレーンの名前(Factory では loop-<ループの ID> / run-<実行の ID>。#2439)", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,140}$" + }, + "ReleaseRequestId": { + "description": "NDF が作る配布の依頼の id(<レーンの名前>-r<乱数>)", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,160}$" + }, + "ReleaseCommit": { + "type": "string", + "pattern": "^[0-9a-f]{7,64}$" + }, + "ReleaseVersion": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "NumberList": { + "type": "array", + "items": { "type": "integer", "minimum": 1 } + }, + "ReleaseRequestedPayload": { + "description": "実行が本番の配布の代わりに出した配布の依頼(#2439)。NDF の置き場の requests/ の写し", + "type": "object", + "additionalProperties": false, + "required": ["request_id", "lane", "prs", "issues", "ndf_version", "requested_at"], + "properties": { + "request_id": { "$ref": "#/$defs/ReleaseRequestId" }, + "lane": { "$ref": "#/$defs/ReleaseLaneName" }, + "prs": { "$ref": "#/$defs/NumberList" }, + "issues": { "$ref": "#/$defs/NumberList" }, + "ndf_version": { "type": "string", "minLength": 1, "maxLength": 200 }, + "requested_at": { "type": "string", "format": "date-time" } + } + }, + "ReleasePresentedLane": { + "type": "object", + "additionalProperties": false, + "required": ["lane", "requested"], + "properties": { + "lane": { "$ref": "#/$defs/ReleaseLaneName" }, + "requested": { "description": "そのレーンが配布の依頼を出したか", "type": "boolean" } + } + }, + "ReleasePresentedPayload": { + "description": "配布の実行が承認資料をそろえた提示(#2439)。auto は MVV 判定が承認ゲート 2 を通したとき真", + "type": "object", + "additionalProperties": false, + "required": ["seq", "k", "sha", "version", "prs", "lanes", "added_prs", "auto"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "k": { "description": "提示の番号。出し直しは 2", "type": "integer", "enum": [1, 2] }, + "sha": { "$ref": "#/$defs/ReleaseCommit" }, + "version": { "$ref": "#/$defs/ReleaseVersion" }, + "prs": { "$ref": "#/$defs/NumberList" }, + "lanes": { "type": "array", "items": { "$ref": "#/$defs/ReleasePresentedLane" } }, + "added_prs": { "$ref": "#/$defs/NumberList" }, + "auto": { "type": "boolean" } + } + }, + "ReleaseProgressPayload": { + "description": "配布の実行の本番の配布が届いた段(#2439)", + "type": "object", + "additionalProperties": false, + "required": ["seq", "stage"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "stage": { "type": "string", "enum": ["release_branch", "develop_merged", "prod_pr_opened", "main_merged", "tagged"] } + } + }, + "ReleaseSettledPayload": { + "description": "配布の実行の結果(#2439)", + "type": "object", + "additionalProperties": false, + "required": ["seq", "result", "prs", "lanes"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "result": { "type": "string", "enum": ["released", "failed"] }, + "version": { "$ref": "#/$defs/ReleaseVersion" }, + "prod_pr_url": { "type": "string", "format": "uri" }, + "sha": { "$ref": "#/$defs/ReleaseCommit" }, + "prs": { "$ref": "#/$defs/NumberList" }, + "lanes": { "type": "array", "items": { "$ref": "#/$defs/ReleaseLaneName" } }, + "reason": { "type": "string", "maxLength": 4000 } + } + }, + "ReleaseAnsweredPayload": { + "description": "Factory のオーガナイザーが範囲の実行へ返した配布の答え(#2439)", + "type": "object", + "additionalProperties": false, + "required": ["seq", "result"], + "properties": { + "seq": { "type": "integer", "minimum": 1 }, + "request_id": { "$ref": "#/$defs/ReleaseRequestId" }, + "result": { "type": "string", "enum": ["released", "rejected"] }, + "version": { "$ref": "#/$defs/ReleaseVersion" }, + "prod_pr_url": { "type": "string", "format": "uri" } + } + }, "JobStartedEvent": { "allOf": [{ "$ref": "#/$defs/EventCommon" }], "type": "object", @@ -495,6 +675,56 @@ "payload": { "$ref": "#/$defs/LoopStoppedPayload" } }, "unevaluatedProperties": false + }, + "ReleaseRequestedEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.requested" }, + "payload": { "$ref": "#/$defs/ReleaseRequestedPayload" } + }, + "unevaluatedProperties": false + }, + "ReleasePresentedEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.presented" }, + "payload": { "$ref": "#/$defs/ReleasePresentedPayload" } + }, + "unevaluatedProperties": false + }, + "ReleaseProgressEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.progress" }, + "payload": { "$ref": "#/$defs/ReleaseProgressPayload" } + }, + "unevaluatedProperties": false + }, + "ReleaseSettledEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.settled" }, + "payload": { "$ref": "#/$defs/ReleaseSettledPayload" } + }, + "unevaluatedProperties": false + }, + "ReleaseAnsweredEvent": { + "allOf": [{ "$ref": "#/$defs/EventCommon" }], + "type": "object", + "required": ["type", "payload"], + "properties": { + "type": { "const": "release.answered" }, + "payload": { "$ref": "#/$defs/ReleaseAnsweredPayload" } + }, + "unevaluatedProperties": false } } } diff --git a/factory/contracts/schemas/run-job-instruction.schema.json b/factory/contracts/schemas/run-job-instruction.schema.json index 78d47b5fb..435eddaf0 100644 --- a/factory/contracts/schemas/run-job-instruction.schema.json +++ b/factory/contracts/schemas/run-job-instruction.schema.json @@ -20,7 +20,7 @@ "JOB_ID": { "description": "冪等の鍵の頭", "type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$" }, "REPO": { "description": "取り込むリポジトリ(owner/name)", "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, "NDF_VERSION": { "description": "スプリントの開始で固定した NDF の版", "type": "string", "minLength": 1 }, - "START": { "description": "new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始める", "type": "string", "enum": ["new", "resume", "loop"] }, + "START": { "description": "new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始め、release は配布の実行の手(RELEASE_STEP)を流す", "type": "string", "enum": ["new", "resume", "loop", "release"] }, "ISSUES": { "description": "スプリントへ入れる課題の番号(カンマ区切り)", "type": "string", "pattern": "^[1-9][0-9]*(,[1-9][0-9]*)*$" }, "APPROVAL_REQUEST_ID": { "description": "答えの出た承認の依頼の識別子。答えの本文は載せない。無い resume は答えを書かずに続ける", "type": "string", "minLength": 1 }, "LOOP_NAME": { "description": "NDF の開発ループの名前。loop と、ループの実行の resume に載せる", "type": "string", "pattern": "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" }, @@ -28,12 +28,23 @@ "LOOP_BUDGET": { "description": "宣言した予算だけを並べる。無ければ NDF の既定", "type": "string", "minLength": 1, "pattern": "^(laps=\\d+(\\.\\d+)?)?(,?usd=\\d+(\\.\\d+)?)?(,?hours=\\d+(\\.\\d+)?)?$" }, "EXCLUDE_ISSUES": { "description": "同じリポジトリの別の実行が持つ課題の番号(カンマ区切り)。ループの選定から外す", "type": "string", "pattern": "^[1-9][0-9]*(,[1-9][0-9]*)*$" }, "LOOP_ANSWERS": { "description": "人の判断待ちへの答え(<番号>=approved|rejected、承認なら # を続けてよい)を , で結ぶ", "type": "string", "pattern": "^[1-9][0-9]*=(approved|rejected)(#[1-9][0-9]*)?(,[1-9][0-9]*=(approved|rejected)(#[1-9][0-9]*)?)*$" }, + "RELEASE_SEQ": { "description": "配布の実行のまとめた配布の連番(#2439)", "type": "string", "pattern": "^[1-9][0-9]*$" }, + "RELEASE_STEP": { "description": "配布の実行の手。present / represent は提示を作り、confirm: は確定した提示 k の本番の配布を流し、continue は止まったプランを続ける", "type": "string", "enum": ["present", "represent", "confirm:1", "confirm:2", "continue"] }, + "RELEASE_REQUEST_ID": { "description": "手動の実行の再開で、答えの出た配布の依頼の id", "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,160}$" }, "INGEST_URL": { "description": "進捗の受け口の runner の経路(POST /ingest/events)の URL", "type": "string", "format": "uri" }, "INGEST_TOKEN_SECRET_ARN": { "description": "このジョブだけに効く実行ジョブの token の置き場", "type": "string", "pattern": "^arn:aws[a-z-]*:secretsmanager:" }, "EXECUTION_MODE": { "description": "スプリントの開始でアカウントから写した実行の形態", "type": "string", "enum": ["local", "byok", "managed"] }, "MODEL_CREDENTIAL_SECRET_ARN": { "description": "利用者の Anthropic API キーか、引き受けるロールの ARN の置き場", "type": "string", "pattern": "^arn:aws[a-z-]*:secretsmanager:" } }, "allOf": [ + { + "if": { "properties": { "START": { "const": "release" } }, "required": ["START"] }, + "then": { + "required": ["RELEASE_SEQ", "RELEASE_STEP"], + "not": { "anyOf": [{ "required": ["ISSUES"] }, { "required": ["LOOP_NAME"] }, { "required": ["LOOP_MILESTONES"] }] } + }, + "else": { "not": { "anyOf": [{ "required": ["RELEASE_SEQ"] }, { "required": ["RELEASE_STEP"] }] } } + }, { "if": { "properties": { "START": { "const": "new" } }, "required": ["START"] }, "then": { "required": ["ISSUES"] } diff --git a/factory/contracts/scripts/generate.ts b/factory/contracts/scripts/generate.ts index 7ac735cf1..075581fc7 100644 --- a/factory/contracts/scripts/generate.ts +++ b/factory/contracts/scripts/generate.ts @@ -23,6 +23,8 @@ export function generateFromSchema(schema: JSONSchema, name: string): Promise-r<乱数>) + */ +export type ReleaseRequestId = string; +/** + * 並列のレーンの名前(Factory では loop-<ループの ID> / run-<実行の ID>。#2439) + */ +export type ReleaseLaneName = string; +export type NumberList = number[]; +export type ReleasePresentedEvent = EventCommon & { + type: "release.presented"; + payload: ReleasePresentedPayload; +}; +export type ReleaseCommit = string; +export type ReleaseVersion = string; +export type ReleaseProgressEvent = EventCommon & { + type: "release.progress"; + payload: ReleaseProgressPayload; +}; +export type ReleaseSettledEvent = EventCommon & { + type: "release.settled"; + payload: ReleaseSettledPayload; +}; +export type ReleaseAnsweredEvent = EventCommon & { + type: "release.answered"; + payload: ReleaseAnsweredPayload; +}; /** * どの種類にも共通の列 @@ -141,11 +177,11 @@ export interface EventCommon { } export interface JobStartedPayload { ndf_version: string; - start: "new" | "resume" | "loop"; + start: "new" | "resume" | "loop" | "release"; } export interface PhaseChangedPayload { /** - * フェーズの名前。集合は #2172 が決めるまで列挙にしない + * フェーズの名前。5 つの名前(設計・実装・検査・取り込み・仕上げ)は src/phases.ts が持ち、ここでは列挙で縛らない */ phase: string; issues: number[]; @@ -156,6 +192,34 @@ export interface StepRecordedPayload { seconds: number; summary: string; pull_request?: PullRequestRef; + /** + * その工程が属するフェーズの名前。省いたときは同じ実行ジョブの直前の phase.changed から導く + */ + phase?: string; + /** + * その工程が受け持つ課題。省いたときも空のときもスプリント全体の工程 + * + * @maxItems 50 + */ + issues?: number[]; + /** + * その工程の時間の窓のコミットが触ったファイル。省いたときは記録なし、空の配列は触らなかった + * + * @maxItems 100 + */ + files?: StepFile[]; + /** + * 上限で files から外した数 + */ + files_omitted?: number; + commit_range?: CommitRange; + /** + * その工程の間に決めたこと。省いたときは記録なし、空の配列は決めたことが無かった + * + * @maxItems 20 + */ + decisions?: StepDecision[]; + output?: StepOutput; } /** * このステップが作った・更新した Pull Request @@ -164,6 +228,37 @@ export interface PullRequestRef { repo: RepoFullName; number: PullRequestNumber; } +export interface StepFile { + path: string; + /** + * null はバイナリ(行数が無い) + */ + added: number | null; + /** + * null はバイナリ(行数が無い) + */ + deleted: number | null; +} +/** + * 時間の窓のコミットの最初の親と最後のコミット + */ +export interface CommitRange { + from: string; + to: string; +} +export interface StepDecision { + question: string; + answer: string; + by: "ai" | "user"; +} +/** + * 作業の報告(report)かログ(log)。text は UTF-8 で 32,768 バイト以下で、truncated は上限で切ったこと + */ +export interface StepOutput { + kind: "report" | "log"; + text: string; + truncated: boolean; +} export interface AttentionPayload { reason: string; summary: string; @@ -176,6 +271,12 @@ export interface GateReachedPayload { */ material: string; loop?: LoopRef; + /** + * 答えの選択肢の制限。配布の実行が main へのマージの後に判断で止まったときは continue だけを載せる(#2439 の I63) + * + * @minItems 1 + */ + choices?: ["continue" | "reject", ...("continue" | "reject")[]]; } /** * 開発ループの実行のとき、ループの名前と周回の番号(#2347) @@ -204,9 +305,9 @@ export interface JobFinishedPayload { result: "gate" | "stopped" | "done" | "failed"; summary: string; /** - * stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限 + * stopped の理由。section_limit は区間の上限、usage_limit は local の利用上限。release_requested は gate の理由で、手動の実行が配布の依頼を出して答えを待つ(#2439) */ - reason?: "section_limit" | "usage_limit"; + reason?: "section_limit" | "usage_limit" | "release_requested"; /** * usage_limit のときだけ。利用上限のリセット時刻(読めなければ null) */ @@ -329,3 +430,67 @@ export interface LoopStoppedPayload { detail: string; pending: LoopPending[]; } +/** + * 実行が本番の配布の代わりに出した配布の依頼(#2439)。NDF の置き場の requests/ の写し + */ +export interface ReleaseRequestedPayload { + request_id: ReleaseRequestId; + lane: ReleaseLaneName; + prs: NumberList; + issues: NumberList; + ndf_version: string; + requested_at: string; +} +/** + * 配布の実行が承認資料をそろえた提示(#2439)。auto は MVV 判定が承認ゲート 2 を通したとき真 + */ +export interface ReleasePresentedPayload { + seq: number; + /** + * 提示の番号。出し直しは 2 + */ + k: 1 | 2; + sha: ReleaseCommit; + version: ReleaseVersion; + prs: NumberList; + lanes: ReleasePresentedLane[]; + added_prs: NumberList; + auto: boolean; +} +export interface ReleasePresentedLane { + lane: ReleaseLaneName; + /** + * そのレーンが配布の依頼を出したか + */ + requested: boolean; +} +/** + * 配布の実行の本番の配布が届いた段(#2439) + */ +export interface ReleaseProgressPayload { + seq: number; + stage: "release_branch" | "develop_merged" | "prod_pr_opened" | "main_merged" | "tagged"; +} +/** + * 配布の実行の結果(#2439) + */ +export interface ReleaseSettledPayload { + seq: number; + result: "released" | "failed"; + version?: ReleaseVersion; + prod_pr_url?: string; + sha?: ReleaseCommit; + prs: NumberList; + lanes: ReleaseLaneName[]; + reason?: string; +} +/** + * Factory のオーガナイザーが範囲の実行へ返した配布の答え(#2439) + */ +export interface ReleaseAnsweredPayload { + seq: number; + request_id?: ReleaseRequestId; + result: "released" | "rejected"; + version?: ReleaseVersion; + prod_pr_url?: string; +} diff --git a/factory/contracts/src/generated/run-job-instruction.ts b/factory/contracts/src/generated/run-job-instruction.ts index 5b89e046d..9f3323b82 100644 --- a/factory/contracts/src/generated/run-job-instruction.ts +++ b/factory/contracts/src/generated/run-job-instruction.ts @@ -21,9 +21,9 @@ export type RunJobInstruction = { */ NDF_VERSION: string; /** - * new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始める + * new は課題から始め、resume は保存した状態から続け、loop は開発ループの周回を新しく始め、release は配布の実行の手(RELEASE_STEP)を流す */ - START: "new" | "resume" | "loop"; + START: "new" | "resume" | "loop" | "release"; /** * スプリントへ入れる課題の番号(カンマ区切り) */ @@ -52,6 +52,18 @@ export type RunJobInstruction = { * 人の判断待ちへの答え(<番号>=approved|rejected、承認なら # を続けてよい)を , で結ぶ */ LOOP_ANSWERS?: string; + /** + * 配布の実行のまとめた配布の連番(#2439) + */ + RELEASE_SEQ?: string; + /** + * 配布の実行の手。present / represent は提示を作り、confirm: は確定した提示 k の本番の配布を流し、continue は止まったプランを続ける + */ + RELEASE_STEP?: "present" | "represent" | "confirm:1" | "confirm:2" | "continue"; + /** + * 手動の実行の再開で、答えの出た配布の依頼の id + */ + RELEASE_REQUEST_ID?: string; /** * 進捗の受け口の runner の経路(POST /ingest/events)の URL */ diff --git a/factory/contracts/src/index.ts b/factory/contracts/src/index.ts index be7f51f3b..34313d8f2 100644 --- a/factory/contracts/src/index.ts +++ b/factory/contracts/src/index.ts @@ -15,7 +15,7 @@ export type * from "./generated/ingest-response.ts"; export type * from "./generated/local-runner-declaration.ts"; export { progressEventSchema, runJobInstructionSchema, ingestResponseSchema, localRunnerDeclarationSchema }; -// 進捗の事象の 16 種類と、定義の中の名前($defs) +// 進捗の事象の 21 種類と、定義の中の名前($defs) export const EVENT_DEFINITIONS = { "job.started": "JobStartedEvent", "phase.changed": "PhaseChangedEvent", @@ -33,6 +33,11 @@ export const EVENT_DEFINITIONS = { "section.started": "SectionStartedEvent", "loop.lap_closed": "LoopLapClosedEvent", "loop.stopped": "LoopStoppedEvent", + "release.requested": "ReleaseRequestedEvent", + "release.presented": "ReleasePresentedEvent", + "release.progress": "ReleaseProgressEvent", + "release.settled": "ReleaseSettledEvent", + "release.answered": "ReleaseAnsweredEvent", } as const satisfies Record; export type EventType = keyof typeof EVENT_DEFINITIONS; @@ -94,9 +99,23 @@ export function isIngestResponse(value: unknown): boolean { return responseValidator(value) as boolean; } -// 手元のランナーの申告(登録と心拍の本文の一部)を定義で確かめる +// 手元のマシンの申告(登録と心拍の本文の一部)を定義で確かめる export function validateLocalRunnerDeclaration(value: unknown): ContractResult { return declarationValidator(value) ? { ok: true, value: value as LocalRunnerDeclaration } : { ok: false, violation: firstViolation(declarationValidator) }; } + +export { PHASE_NAMES, CLOSING_STEPS, isPhaseName, type PhaseName } from "./phases.ts"; + +// 実行のレーンの名前。ループはループ 1 つ、手動の実行は実行 1 つがレーンになる。#2436 のレーン名の形(小文字・32 字まで)に +// 収めるため、ループの名前と実行の識別子を切り詰める。ランナーの laneName と API の laneOf が同じ名前を出すための唯一の式 +export const organizerLaneName = (sprintRunId: string, loopName: string | null | undefined): string => + loopName ? `loop-${loopName.toLowerCase().slice(0, 27)}` : `run-${sprintRunId.toLowerCase().slice(0, 16)}`; + +// 配布の実行の事象の冪等の鍵(#2439)。実行の識別子を頭にする(冪等の鍵の表はサービス全体で 1 つのため、リポジトリごとの +// 連番だけを鍵にしない)。ランナーの送信と API の受け口の照合が同じ式を使うための唯一の定義 +export const releasePresentedKey = (sprintRunId: string, k: number) => `${sprintRunId}:presented:${k}`; +export const releaseProgressKey = (sprintRunId: string, stage: string) => `${sprintRunId}:progress:${stage}`; +// 結果は種類ごとに鍵を分ける。同じ実行が failed を送った後に再開して released を送れるように(鍵が 1 つだと重複として捨てられる) +export const releaseSettledKey = (sprintRunId: string, result: string) => `${sprintRunId}:settled:${result}`; diff --git a/factory/contracts/src/phases.ts b/factory/contracts/src/phases.ts new file mode 100644 index 000000000..7823b4c08 --- /dev/null +++ b/factory/contracts/src/phases.ts @@ -0,0 +1,16 @@ +// フェーズの名前(#2172)。phase.changed と step.recorded の phase が運ぶ 5 つの名前の正はここだけにある。 +// 定義(schemas/)では文字列のままにし、列挙で縛らない(NDF の版がフェーズを足しても事象を 400 にしない)。 +// 画面も読むため、この入口(@ndf-factory/contracts/phases)は依存を持たない +export const PHASE_NAMES = ["設計", "実装", "検査", "取り込み", "仕上げ"] as const; + +export type PhaseName = (typeof PHASE_NAMES)[number]; + +export function isPhaseName(value: unknown): value is PhaseName { + return typeof value === "string" && (PHASE_NAMES as readonly string[]).includes(value); +} + +// 仕上げのフェーズに常に置く工程(振り返りと棚卸し)。step は step.recorded の step の値 +export const CLOSING_STEPS = [ + { label: "振り返り", step: "retro" }, + { label: "棚卸し", step: "refine" }, +] as const; diff --git a/factory/contracts/src/views.ts b/factory/contracts/src/views.ts new file mode 100644 index 000000000..ba7c1c28e --- /dev/null +++ b/factory/contracts/src/views.ts @@ -0,0 +1,12 @@ +// 画面の応答の形のうち、API と画面の両方が書く形(#2172)。正はここだけにある。 +// 画面も読むため、この入口(@ndf-factory/contracts/views)は依存を持たない + +// 工程の中身(GET .../steps/{key})の本体。工程の行(StepRow)に足す項目 +export interface StepDetailBody { + files: { path: string; added: number | null; deleted: number | null; url: string | null }[] | null; + files_omitted: number | null; + commit_range: { from: string; to: string } | null; + decisions: { question: string; answer: string; by: "ai" | "user" }[] | null; + output: { kind: "report" | "log"; text: string; truncated: boolean } | null; + diff_url: string | null; +} diff --git a/factory/contracts/tests/definitions.test.ts b/factory/contracts/tests/definitions.test.ts index 2d27f8657..646cb2d23 100644 --- a/factory/contracts/tests/definitions.test.ts +++ b/factory/contracts/tests/definitions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { EVENT_TYPES, + PHASE_NAMES, + isPhaseName, isIngestResponse, progressEventSchema, runJobInstructionSchema, @@ -27,6 +29,9 @@ const SPEC_INSTRUCTION_INPUTS = [ "LOOP_BUDGET", "EXCLUDE_ISSUES", "LOOP_ANSWERS", + "RELEASE_SEQ", + "RELEASE_STEP", + "RELEASE_REQUEST_ID", ]; // 同じ確定仕様の「進捗の事象の種類と受け口」の種類(13 種類)と、区間と開発ループの 3 種類(#2347) @@ -47,6 +52,11 @@ const SPEC_EVENT_TYPES = [ "section.started", "loop.lap_closed", "loop.stopped", + "release.requested", + "release.presented", + "release.progress", + "release.settled", + "release.answered", ]; // 同じ確定仕様の「進捗の正本」の列から、受け口が作る order_key・pr_ref を除いたもの @@ -83,6 +93,35 @@ const PAYLOADS: Record> = { pending: [{ number: 2348, reason: "MVV の外の疑い", lap: 3 }], }, "loop.stopped": { loop: "lfactory2", condition: "budget_usd", detail: '{"values":{"usd":60}}', pending: [] }, + "release.requested": { + request_id: "loop-l1-r3f9a1c2e4b7d", + lane: "loop-l1", + prs: [2440], + issues: [2439], + ndf_version: "10.27.16", + requested_at: "2026-11-02T12:00:00Z", + }, + "release.presented": { + seq: 1, + k: 1, + sha: "0123abcd", + version: "10.28.0", + prs: [2440], + lanes: [{ lane: "loop-l1", requested: true }], + added_prs: [], + auto: false, + }, + "release.progress": { seq: 1, stage: "main_merged" }, + "release.settled": { + seq: 1, + result: "released", + version: "10.28.0", + prod_pr_url: "https://github.com/o/r/pull/9", + sha: "0123abcd", + prs: [2440], + lanes: ["loop-l1"], + }, + "release.answered": { seq: 1, request_id: "loop-l1-r3f9a1c2e4b7d", result: "released", version: "10.28.0" }, }; function event(type: string, patch: Record = {}) { @@ -166,6 +205,16 @@ describe("実行ジョブの指示の定義(受け入れ条件 1)", () => { ).toBe(true); }); + it("START が release は RELEASE_SEQ と RELEASE_STEP を要り、ほかの START は配布の手を許さない(#2439)", () => { + const release = { START: "release", ISSUES: undefined, RELEASE_SEQ: "3", RELEASE_STEP: "confirm:1" }; + expect(validateRunJobInstruction(instruction(release)).ok).toBe(true); + expect(validateRunJobInstruction(instruction({ ...release, RELEASE_STEP: undefined })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ ...release, RELEASE_STEP: "confirm:3" })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ ...release, ISSUES: "1" })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ RELEASE_SEQ: "3", RELEASE_STEP: "present" })).ok).toBe(false); + expect(validateRunJobInstruction(instruction({ START: "resume", ISSUES: undefined, RELEASE_REQUEST_ID: "run-sr1-rabc" })).ok).toBe(true); + }); + it("知らない入力・番号でない ISSUES は落ちる", () => { expect(validateRunJobInstruction({ ...instruction(), EXTRA: "x" }).ok).toBe(false); expect(validateRunJobInstruction(instruction({ ISSUES: "2171,abc" })).ok).toBe(false); @@ -173,7 +222,7 @@ describe("実行ジョブの指示の定義(受け入れ条件 1)", () => { }); describe("進捗の事象の定義(受け入れ条件 1・11)", () => { - it("種類が確定仕様の 13 種類と #2347 の 3 種類に 1 対 1 に対応する", () => { + it("種類が確定仕様の 13 種類と #2347 の 3 種類と #2439 の 5 種類に 1 対 1 に対応する", () => { expect([...EVENT_TYPES].sort()).toEqual([...SPEC_EVENT_TYPES].sort()); expect(progressEventSchema.oneOf).toHaveLength(SPEC_EVENT_TYPES.length); }); @@ -233,6 +282,42 @@ describe("進捗の事象の定義(受け入れ条件 1・11)", () => { expect(step({ repo: "o/r", number: 0 })).toBe(false); }); + it("step.recorded の工程の中身(#2172)はどれも任意で、足す前の形も足した形も通り、上限と形の違いで落ちる", () => { + const step = (patch: Record) => + validateProgressEvent(event("step.recorded", { payload: { ...PAYLOADS["step.recorded"], ...patch } })).ok; + const sha = "a".repeat(40); + const full = { + phase: "実装", + issues: [2172], + files: [{ path: "factory/a.ts", added: 3, deleted: 1 }, { path: "img.png", added: null, deleted: null }], + files_omitted: 0, + commit_range: { from: sha, to: "b".repeat(40) }, + decisions: [{ question: "どちらにする", answer: "A", by: "ai" }], + output: { kind: "report", text: "## 作業の報告", truncated: false }, + }; + expect(step({})).toBe(true); + expect(step(full)).toBe(true); + // 列挙で縛らない(NDF の版がフェーズを足しても残る) + expect(step({ phase: "準備" })).toBe(true); + expect(step({ issues: [], files: [], decisions: [] })).toBe(true); + expect(step({ phase: "" })).toBe(false); + expect(step({ issues: Array.from({ length: 51 }, (_, i) => i + 1) })).toBe(false); + expect(step({ files: Array.from({ length: 101 }, (_, i) => ({ path: `f${i}`, added: 0, deleted: 0 })) })).toBe(false); + expect(step({ files: [{ path: "x".repeat(513), added: 0, deleted: 0 }] })).toBe(false); + expect(step({ commit_range: { from: "abc", to: sha } })).toBe(false); + expect(step({ decisions: [{ question: "q", answer: "a", by: "someone" }] })).toBe(false); + expect(step({ decisions: Array.from({ length: 21 }, () => ({ question: "q", answer: "a", by: "ai" })) })).toBe(false); + expect(step({ output: { kind: "diff", text: "", truncated: false } })).toBe(false); + expect(step({ output: { kind: "log", text: "x".repeat(32_769), truncated: true } })).toBe(false); + }); + + it("フェーズの名前は 5 つで、isPhaseName は列挙の外を偽にする(#2172)", () => { + expect(PHASE_NAMES).toEqual(["設計", "実装", "検査", "取り込み", "仕上げ"]); + expect(PHASE_NAMES.every(isPhaseName)).toBe(true); + expect(isPhaseName("配布(本番)")).toBe(false); + expect(isPhaseName(null)).toBe(false); + }); + it("issue.requested は起票先・題・本文の 3 つで、題は 256 字まで・本文は空にできない(#2216)", () => { const requested = (payload: Record) => validateProgressEvent(event("issue.requested", { payload })).ok; const base = PAYLOADS["issue.requested"]; @@ -301,7 +386,7 @@ describe("進捗の事象の定義(受け入れ条件 1・11)", () => { }); }); -describe("手元のランナーの申告の定義(#2347)", () => { +describe("手元のマシンの申告の定義(#2347)", () => { it("runner_id は 32 字の英数字で、max_jobs は 1〜16 の整数・任意", () => { const id = "a".repeat(32); expect(validateLocalRunnerDeclaration({ runner_id: id }).ok).toBe(true); diff --git a/factory/package-lock.json b/factory/package-lock.json index 9c8006040..9f99ff596 100644 --- a/factory/package-lock.json +++ b/factory/package-lock.json @@ -7449,6 +7449,7 @@ "web": { "name": "@ndf-factory/web", "dependencies": { + "@ndf-factory/contracts": "*", "i18next": "^26.7.1", "oidc-client-ts": "^3.5.0", "react": "^19.3.1", diff --git a/factory/runner/src/capacity.ts b/factory/runner/src/capacity.ts index a2c3391c0..4a51ce517 100644 --- a/factory/runner/src/capacity.ts +++ b/factory/runner/src/capacity.ts @@ -1,5 +1,5 @@ // 同時のジョブの数の目安(決定 13)。2026-09-25 の実測(8 コア・30 GB で全体テストの同時実行 6 本が上限)から、 -// 1 本あたり 4/3 コア・5 GiB とする。手元のランナー(#2189)が登録と心拍の前に呼び、申告が目安を超えれば警告する(申告は変えない) +// 1 本あたり 4/3 コア・5 GiB とする。手元のマシン(#2189)が登録と心拍の前に呼び、申告が目安を超えれば警告する(申告は変えない) import { cpus, totalmem } from "node:os"; const GIB = 1024 ** 3; diff --git a/factory/runner/src/main.ts b/factory/runner/src/main.ts index 6ae82c910..8ef7fce65 100644 --- a/factory/runner/src/main.ts +++ b/factory/runner/src/main.ts @@ -3,6 +3,7 @@ import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { validateRunJobInstruction, type GateReachedEvent, type JobFinishedEvent, type ProgressEvent, type RunJobInstruction } from "@ndf-factory/contracts"; import { advancedSince, answersToWrite, currentLapOf, lapClosedEvents, loopStoppedEvent, markOf, type LoopFile } from "./loop-file.ts"; +import { openRequests, releaseAnswerDirective, releaseBodies, writeAnswers, type OrganizerAnswers, type OrganizerFiles } from "./organizer.ts"; import type { SectionEnd } from "./sections.ts"; import { createSender, type SendResult } from "./send.ts"; import { common, finishedResult, truncateGate, type JobIdentity, type ReportResult } from "./translate.ts"; @@ -21,6 +22,8 @@ export interface JobRuntime { saveState(url: string): Promise; // 開発ループの記録(.ndf/loop/<名>/loop.json)を読む。無ければ null readLoop(name: string): Promise; + // Factory のオーガナイザーとの置き場(#2439)。無ければ置き場の翻訳をしない + organizer?: OrganizerFiles; // 人の判断待ちへの答えを NDF の loop answer で書く answerLoop(name: string, answer: { number: number; decision: "approved" | "rejected"; pr: number | null }): Promise; // 承認の依頼の答えを NDF へ記録する(ゲートは sprint-state.py gate --by user、判断は conductor への指示を返す) @@ -30,7 +33,8 @@ export interface JobRuntime { runConductor( instruction: RunJobInstruction, directive: string | null, - emit: (body: Pick) => Promise, + // at を渡すとその時刻で送る(同じ行から作る事象の時刻を揃える) + emit: (body: Pick, at?: string) => Promise, lapClosed?: () => Promise, ): Promise; } @@ -64,8 +68,8 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { const id: JobIdentity = { sprintRunId: instruction.SPRINT_RUN_ID, jobId: instruction.JOB_ID, repo: instruction.REPO }; const send = createSender({ url: instruction.INGEST_URL, token: () => token, fetch: http, sleep: deps.sleep, stderr }); let seq = 0; - const emit = async (body: Pick): Promise => - send({ ...common(id, ++seq, now().toISOString()), ...body } as ProgressEvent); + const emit = async (body: Pick, at?: string): Promise => + send({ ...common(id, ++seq, at ?? now().toISOString()), ...body } as ProgressEvent); const callApi = async (path: string, body: unknown = {}, missingOk = false) => { const res = await http(apiUrl(instruction.INGEST_URL, path), { @@ -78,6 +82,15 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { return (await res.json()) as Record; }; + const getApi = async (path: string): Promise => { + const res = await http(apiUrl(instruction.INGEST_URL, path), { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) throw new Error(`${path} が ${res.status} を返した`); + return (await res.json()) as T; + }; + // 置き場の事象は受け口の照合と同じ鍵で送る(送り直しでも 1 件。I59) + const sendRelease = async (key: string, body: Pick) => + send({ ...common(id, "finished", now().toISOString()), idempotency_key: key, ...body } as ProgressEvent); + const refreshGitHubToken = async () => runtime.writeGitHubToken((await callApi("/run-jobs/github-token"))!.token!); let failed = false; @@ -86,7 +99,11 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { let report: ReportResult = null; let end: SectionEnd | undefined; let lapAdvanced = false; + // このジョブで新しく出た配布の依頼があり、答えがまだ無い(手動の実行は答えを待って gate で終える。I65) + let releaseRequested = false; + let releaseGate = false; const loopName = instruction.LOOP_NAME ?? null; + const organizer = runtime.organizer ?? null; const timer = (deps.setInterval ?? setInterval)(() => { refreshGitHubToken().catch((err: Error) => stderr(`導入の token を取り直せない: ${err.message}`)); }, GITHUB_TOKEN_REFRESH_MS); @@ -97,13 +114,29 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { if (installed !== instruction.NDF_VERSION) throw new Error(`入った NDF の版が違う(${installed})`); await emit({ type: "job.started", payload: { ndf_version: instruction.NDF_VERSION, start: instruction.START } }); let directive: string | null = null; - if (instruction.START === "resume" || instruction.START === "loop") { - // ループの最初の周回は保存した状態が無い(404)。空から始める - const state = await callApi("/run-jobs/state", { op: "get" }, instruction.START === "loop"); + if (instruction.START === "resume" || instruction.START === "loop" || instruction.START === "release") { + // ループの最初の周回と配布の実行の最初のジョブは保存した状態が無い(404)。空から始める + const state = await callApi("/run-jobs/state", { op: "get" }, instruction.START !== "resume"); if (state) await runtime.restoreState(state.url!); // 承認の依頼の識別子の無い resume(利用上限の後)は答えを書かずに続ける(決定 7) if (instruction.START === "resume" && instruction.APPROVAL_REQUEST_ID) directive = await runtime.recordAnswer(instruction.APPROVAL_REQUEST_ID); + // 配布の実行の判断(承認ゲートの外の問い)への答えは本文を最初の指示へ渡す。承認ゲート 2 の答えは配布のまとめが手(step)にする + if (instruction.START === "release" && instruction.APPROVAL_REQUEST_ID) { + const answer = await getApi<{ kind: string; body?: string | null }>(`/run-jobs/approval-requests/${instruction.APPROVAL_REQUEST_ID}/answer`); + if (answer.kind === "decision") directive = answer.body ?? null; + } } + // 配布の依頼の答えと範囲、配布の実行ならまとめた配布の中身を置き場へ書き、答えの無い依頼を送り直す(#2439) + let requestsBefore = new Set(); + if (organizer) { + await writeAnswers(organizer, await getApi("/run-jobs/organizer/answers")); + if (instruction.START === "release") await organizer.write("batch.json", await getApi("/run-jobs/organizer/batch")); + const open = await openRequests(organizer); + requestsBefore = new Set(open.map((p) => p.request_id)); + for (const p of open) await sendRelease(p.request_id, { type: "release.requested", payload: p }); + if (instruction.RELEASE_REQUEST_ID) directive = releaseAnswerDirective(instruction.RELEASE_REQUEST_ID); + } + const presentedBefore = organizer ? new Set((await organizer.list("presented")).map((f) => f.name)) : new Set(); let lapClosed: (() => Promise) | undefined; let mark = markOf(null); if (loopName) { @@ -114,8 +147,8 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { const out = await runtime.runConductor( instruction, directive, - async (body) => { - await emit(body); + async (body, at) => { + await emit(body, at); }, lapClosed, ); @@ -131,10 +164,25 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { if (stopped) await emit(stopped); lapAdvanced = advancedSince(mark, loopAfter); } + // 置き場の事象は job.finished より前に送る(実行の制御は停止の後に同じ実行の事象として読む) + let mainMerged = false; + if (organizer) { + for (const body of await releaseBodies(organizer, instruction)) { + await sendRelease(body.key, body.event); + if (body.event.type === "release.requested" && !requestsBefore.has(body.key)) releaseRequested = true; + if (body.event.type === "release.progress" && (body.event.payload as { stage: string }).stage === "main_merged") mainMerged = true; + } + releaseGate = (await organizer.list("presented")).some((f) => !presentedBefore.has(f.name) && f.value.auto === false); + } await runtime.saveState((await callApi("/run-jobs/state", { op: "put" }))!.url!); - if (report === "判断が要る" && end?.kind !== "usage_limit") { - gate = { kind: out.gate?.kind ?? "decision", pr_url: out.gate?.pr_url ?? null, material: out.gate?.material ?? summary }; + if (report === "判断が要る" && end?.kind !== "usage_limit" && !(releaseRequested && !loopName)) { + // 配布の実行が承認資料をそろえて止まったら承認ゲート 2。main へのマージの後の判断は続けるだけを選べる(I63) + // 配布の実行の中の止まりは、承認資料をそろえた提示のときだけ gate2。本番のプランの中の「判断が要る」を + // gate2 にすると、確定済みの配布への答えが差し戻しでも本番のプランを流し直す + const kind = releaseGate ? "gate2" : instruction.START === "release" ? "decision" : (out.gate?.kind ?? "decision"); + gate = { kind, pr_url: out.gate?.pr_url ?? null, material: out.gate?.material ?? summary }; if (loopName) gate.loop = { name: loopName, lap: currentLapOf(loopAfter) }; + if (instruction.START === "release" && kind === "decision" && mainMerged) gate.choices = ["continue"]; } } catch (err) { failed = true; @@ -151,7 +199,7 @@ export async function runJob(deps: RunnerDeps): Promise<0 | 1> { const finished: JobFinishedEvent = { ...common(id, "finished", now().toISOString()), type: "job.finished", - payload: finishedPayload(report, failed, end, lapAdvanced, summary), + payload: !failed && releaseRequested && !loopName ? { result: "gate", summary, reason: "release_requested" } : finishedPayload(report, failed, end, lapAdvanced, summary), } as JobFinishedEvent; return (await send(finished)) === "sent" ? 0 : 1; } @@ -171,7 +219,7 @@ export function finishedPayload( return { result: finishedResult(report, failed), summary }; } -// 環境変数から指示を読む。手元のランナー(#2189)は INGEST_TOKEN で token を直に渡す +// 環境変数から指示を読む。手元のマシン(#2189)は INGEST_TOKEN で token を直に渡す export async function loadInstruction(env: Record): Promise<{ instruction: RunJobInstruction; token: string }> { const keys = [ "SPRINT_RUN_ID", @@ -190,6 +238,9 @@ export async function loadInstruction(env: Record): "LOOP_BUDGET", "EXCLUDE_ISSUES", "LOOP_ANSWERS", + "RELEASE_SEQ", + "RELEASE_STEP", + "RELEASE_REQUEST_ID", ]; const raw = Object.fromEntries(keys.filter((k) => env[k] !== undefined).map((k) => [k, env[k]])); const checked = validateRunJobInstruction(raw); diff --git a/factory/runner/src/model.ts b/factory/runner/src/model.ts index 335174ed9..fb28cfc76 100644 --- a/factory/runner/src/model.ts +++ b/factory/runner/src/model.ts @@ -1,6 +1,6 @@ // 形態ごとの claude の呼び方(受け入れ条件 29)。アクセスキーをイメージと環境変数に置かず、 // 子の環境にだけ必要な値を置く。managed はタスクの役割の権限、byok は利用者のキーかロール -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; @@ -51,10 +51,48 @@ export async function writeRoleCredentials( return { configFile }; } -// 子へ渡さない環境変数(秘密の置き場の ARN と、受け口の token。手元のランナーの INGEST_TOKEN も含む) +// 子へ渡さない環境変数(秘密の置き場の ARN と、受け口の token。手元のマシンの INGEST_TOKEN も含む) const PARENT_ONLY = ["INGEST_TOKEN", "INGEST_TOKEN_SECRET_ARN", "MODEL_CREDENTIAL_SECRET_ARN"]; -// モデルの資格。managed と byok では Factory が決めた資格だけを子へ置く。local は利用者が手元で設定した資格をそのまま渡す -const MODEL_CREDENTIALS = ["ANTHROPIC_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"]; +// モデルの資格。managed と byok では Factory が決めた資格だけを子へ置く。local は利用者が手元で設定した資格をそのまま渡す。 +// claude が読む資格(API キー・OAuth の token・認証の token・Bedrock の bearer token・AWS の鍵)をすべて並べる +const MODEL_CREDENTIALS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", +]; + +// 子へそのまま渡すモデルの資格の値(local だけ)。外へ送る工程の中身から伏せる値に足す +export function passedModelSecrets(base: Record, mode: "local" | "byok" | "managed"): string[] { + return mode === "local" ? MODEL_CREDENTIALS.map((k) => base[k] ?? "").filter(Boolean) : []; +} + +// managed で子が使うタスクの役割の資格。子と同じコンテナの資格の口(ECS の credential endpoint)から読む。 +// 口が無い(手元・byok)か読めなければ空。値は更新されるため、呼ぶ側が読んだ値を控えて伏せる値に足す +export async function containerRoleSecrets( + base: Record, + mode: "local" | "byok" | "managed", + get: typeof fetch = fetch, + readToken: (path: string) => Promise = (p) => readFile(p, "utf8"), +): Promise { + if (mode !== "managed") return []; + const url = base.AWS_CONTAINER_CREDENTIALS_FULL_URI ?? (base.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ? `http://169.254.170.2${base.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}` : null); + if (!url) return []; + try { + const auth = base.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE + ? (await readToken(base.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE)).trim() + : base.AWS_CONTAINER_AUTHORIZATION_TOKEN; + const res = await get(url, auth ? { headers: { Authorization: auth } } : {}); + if (!res.ok) return []; + const c = (await res.json()) as { AccessKeyId?: unknown; SecretAccessKey?: unknown; Token?: unknown }; + return [c.AccessKeyId, c.SecretAccessKey, c.Token].filter((v): v is string => typeof v === "string" && v !== ""); + } catch { + return []; + } +} export async function claudeEnv( base: Record, diff --git a/factory/runner/src/organizer.test.ts b/factory/runner/src/organizer.test.ts new file mode 100644 index 000000000..8665badc0 --- /dev/null +++ b/factory/runner/src/organizer.test.ts @@ -0,0 +1,153 @@ +// Factory のオーガナイザーとの置き場の翻訳(#2439)。NDF の契約の例の組(parallel-external.json)を読み、写した事象が契約に合うかを縛る +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { validateProgressEvent, validateRunJobInstruction, type ProgressEvent, type RunJobInstruction } from "@ndf-factory/contracts"; +import { runJob, type JobRuntime } from "./main.ts"; +import { laneName, organizerEnv, releaseBodies, type OrganizerFiles } from "./organizer.ts"; +import { common } from "./translate.ts"; + +const FORM = JSON.parse(readFileSync(new URL("../../../plugins/ndf/contracts/forms/parallel-external.json", import.meta.url), "utf8")) as { + cases: { message: { file: string; body: Record }; valid: boolean }[]; +}; + +function memoryFiles(seed: Record> = {}): OrganizerFiles & { files: Map } { + const files = new Map(Object.entries(seed)); + return { + files, + async write(rel, value) { + files.set(rel, structuredClone(value)); + }, + async list(sub) { + return [...files.entries()] + .filter(([k]) => k.startsWith(`${sub}/`)) + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([k, v]) => ({ name: k.slice(sub.length + 1), value: v as Record })); + }, + }; +} + +const ID = { sprintRunId: "sr1", jobId: "job1", repo: "org-a/repo-a" }; +const full = (key: string, body: Pick) => + ({ ...common(ID, "finished", "2026-11-02T00:00:00.000Z"), idempotency_key: key, ...body }) as ProgressEvent; + +describe("置き場の翻訳(受け入れ条件 10、I59・I68)", () => { + it("契約の例の組の正しいファイルは契約に合う事象へ写り、依頼の形が欠けたもの・知らない段は写らない", async () => { + const valid = FORM.cases.filter((c) => c.valid && /^(requests|presented|progress|results)\//.test(c.message.file)); + expect(valid.length).toBeGreaterThanOrEqual(4); + for (const c of valid) { + const files = memoryFiles({ [c.message.file]: c.message.body }); + const bodies = await releaseBodies(files, { SPRINT_RUN_ID: "sr1", START: "release" }); + expect(bodies.length, c.message.file).toBeGreaterThan(0); + for (const b of bodies) expect(validateProgressEvent(full(b.key, b.event)), `${c.message.file} ${b.event.type}`).toMatchObject({ ok: true }); + } + const invalid = FORM.cases.filter((c) => !c.valid && /^(requests|progress)\//.test(c.message.file) && !c.message.file.startsWith("requests/loop-x")); + for (const c of invalid) { + expect(await releaseBodies(memoryFiles({ [c.message.file]: c.message.body }), { SPRINT_RUN_ID: "sr1", START: "release" })).toEqual([]); + } + }); + + it("答えのある依頼は送り直さず、配布の実行の事象は配布の実行のジョブだけが送る", async () => { + const req = FORM.cases.find((c) => c.valid && c.message.file.startsWith("requests/"))!.message; + const res = FORM.cases.find((c) => c.valid && c.message.file.startsWith("results/"))!.message; + const files = memoryFiles({ [req.file]: req.body, [res.file]: res.body }); + expect((await releaseBodies(files, { SPRINT_RUN_ID: "sr1", START: "loop" })).map((b) => b.event.type)).toEqual(["release.requested"]); + await files.write(`answers/${req.body.id}.json`, { result: "released" }); + expect(await releaseBodies(files, { SPRINT_RUN_ID: "sr1", START: "loop" })).toEqual([]); + }); + + it("レーンの名前は #2436 のレーン名の形に収まり、NDF へ 4 つの環境変数を渡す", () => { + const loop = laneName({ SPRINT_RUN_ID: "x", LOOP_NAME: "LFactory3-very-long-loop-name-over-limit" }); + const run = laneName({ SPRINT_RUN_ID: "0123456789abcdef0123456789abcdef" }); + for (const name of [loop, run]) expect(name).toMatch(/^[a-z0-9][a-z0-9_-]{0,31}$/); + expect(run).toBe("run-0123456789abcdef"); + expect(loop).toBe("loop-lfactory3-very-long-loop-na"); + expect(organizerEnv({ SPRINT_RUN_ID: "sr1", NDF_VERSION: "10.28.0@0123abcd" }, "/w/repo/.ndf/organizer")).toEqual({ + NDF_FACTORY_RUN: "1", + NDF_ORGANIZER_DIR: "/w/repo/.ndf/organizer", + NDF_ORGANIZER_LANE: "run-sr1", + NDF_VERSION: "10.28.0@0123abcd", + }); + }); +}); + +describe("配布の依頼と配布の実行のジョブ(受け入れ条件 7・10、I65)", () => { + const base: RunJobInstruction = { + SPRINT_RUN_ID: "sr1", + JOB_ID: "job1", + REPO: "org-a/repo-a", + NDF_VERSION: "10.27.16", + START: "new", + ISSUES: "1", + INGEST_URL: "https://api.example/ingest/events", + INGEST_TOKEN_SECRET_ARN: "arn:aws:secretsmanager:us-east-1:1:secret:t", + EXECUTION_MODE: "managed", + }; + + function harness(organizer: OrganizerFiles, conductor: JobRuntime["runConductor"]) { + const events: ProgressEvent[] = []; + const fetchImpl = (async (url: string, init: RequestInit = {}) => { + if (url.endsWith("/ingest/events")) { + events.push(JSON.parse(String(init.body)) as ProgressEvent); + return new Response(null, { status: 202 }); + } + if (url.endsWith("/run-jobs/organizer/answers")) return Response.json({ answers: [], ranges: [] }); + if (url.endsWith("/run-jobs/organizer/batch")) return Response.json({ seq: 1, step: "present", lanes: [], presentations: [], choices: ["continue", "reject"] }); + if (url.endsWith("/run-jobs/state") && String(init.body).includes('"get"')) return new Response(null, { status: 404 }); + return Response.json(url.endsWith("github-token") ? { token: "ghs" } : { url: "https://state.example/x" }); + }) as typeof fetch; + const runtime: JobRuntime = { + organizer, + writeGitHubToken: async () => undefined, + clone: async () => undefined, + installNdf: async (v) => v, + restoreState: async () => undefined, + saveState: async () => undefined, + readLoop: async () => null, + answerLoop: vi.fn(async () => undefined), + recordAnswer: vi.fn(async () => null), + runConductor: conductor, + }; + return { events, run: (inst: RunJobInstruction) => runJob({ instruction: inst, token: "tok", runtime, fetch: fetchImpl, setInterval: () => 1, clearInterval: () => undefined, stderr: () => undefined }) }; + } + + it("手動の実行が配布の依頼を出すと release.requested を依頼の id の鍵で送り、gate(release_requested)で終える", async () => { + const req = FORM.cases.find((c) => c.valid && c.message.file.startsWith("requests/"))!.message; + const files = memoryFiles(); + const t = harness(files, async () => { + await files.write(req.file, req.body); + return { report: "判断が要る", summary: "配布待ち", gate: null }; + }); + expect(await t.run(base)).toBe(0); + const requested = t.events.find((e) => e.type === "release.requested")!; + expect(requested.idempotency_key).toBe(req.body.id); + expect(validateProgressEvent(requested).ok).toBe(true); + expect(t.events.some((e) => e.type === "gate.reached")).toBe(false); + expect(t.events.at(-1)).toMatchObject({ type: "job.finished", payload: { result: "gate", reason: "release_requested" } }); + expect(validateProgressEvent(t.events.at(-1)!).ok).toBe(true); + }); + + it("配布の実行はまとめた配布の中身を置き場へ書き、承認資料で止まれば提示と gate2 を送る", async () => { + const files = memoryFiles(); + const inst = { ...base, START: "release" as const, ISSUES: undefined, RELEASE_SEQ: "1", RELEASE_STEP: "present" as const }; + expect(validateRunJobInstruction(Object.fromEntries(Object.entries(inst).filter(([, v]) => v !== undefined))).ok).toBe(true); + const presented = FORM.cases.find((c) => c.valid && c.message.file.startsWith("presented/"))!.message; + const t = harness(files, async () => { + expect(files.files.get("batch.json")).toMatchObject({ seq: 1, step: "present" }); + await files.write(presented.file, presented.body); + return { report: "判断が要る", summary: "承認ゲート 2", gate: null }; + }); + await t.run(inst); + expect(t.events.map((e) => e.type)).toEqual(["job.started", "release.presented", "gate.reached", "job.finished"]); + expect(t.events.find((e) => e.type === "release.presented")!.idempotency_key).toBe("sr1:presented:1"); + expect(t.events.find((e) => e.type === "gate.reached")!.payload).toMatchObject({ kind: "gate2" }); + for (const e of t.events) expect(validateProgressEvent(e).ok, e.type).toBe(true); + }); + + it("配布の実行が提示を書かずに本番のプランの中で止まれば、conductor が gate2 と言っても判断(decision)で送る", async () => { + const files = memoryFiles(); + const inst = { ...base, START: "release" as const, ISSUES: undefined, RELEASE_SEQ: "1", RELEASE_STEP: "confirm:1" as const }; + const t = harness(files, async () => ({ report: "判断が要る", summary: "本番のプランが止まった", gate: { kind: "gate2", pr_url: null, material: "資料" } })); + await t.run(inst); + expect(t.events.find((e) => e.type === "gate.reached")!.payload).toMatchObject({ kind: "decision" }); + }); +}); diff --git a/factory/runner/src/organizer.ts b/factory/runner/src/organizer.ts new file mode 100644 index 000000000..268fc43a6 --- /dev/null +++ b/factory/runner/src/organizer.ts @@ -0,0 +1,167 @@ +// Factory のオーガナイザーとの置き場の翻訳(#2439 の腐敗防止層)。NDF が置き場(NDF_ORGANIZER_DIR)へ書く依頼・提示・届いた段・結果の +// ファイルを進捗の事象へ写し、API の答え・配布済みの範囲・まとめた配布の中身をファイルへ書く。形は NDF の契約 +// plugins/ndf/contracts/forms/parallel-external.json。API は NDF のファイルを読まない +import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + organizerLaneName, + type ProgressEvent, + type ReleasePresentedPayload, + type ReleaseProgressPayload, + type ReleaseRequestedPayload, + type ReleaseSettledPayload, + releasePresentedKey, + releaseProgressKey, + releaseSettledKey, + type RunJobInstruction, +} from "@ndf-factory/contracts"; + +// 置き場(取り込んだリポジトリの .ndf の下。状態の保存に入れ、周回と再開をまたいで残す) +export const ORGANIZER_DIR = ".ndf/organizer"; +const STAGES = new Set(["release_branch", "develop_merged", "prod_pr_opened", "main_merged", "tagged"]); + +// レーンの名前。式は契約の organizerLaneName で、API の Factory のオーガナイザーが同じ式で依頼の無いレーンを名付ける +export function laneName(inst: Pick): string { + return organizerLaneName(inst.SPRINT_RUN_ID, inst.LOOP_NAME); +} + +// NDF へ渡す環境変数(I68)。Factory の実行ジョブでは 1 台の中のオーガナイザーを起動せず、本番の配布は依頼として出す +// NDF_VERSION は入れた NDF の版(開発版は @)。配布の依頼の ndf_version が固定したコミットを落とさないように渡す +export function organizerEnv(inst: Pick, dir: string): Record { + return { NDF_FACTORY_RUN: "1", NDF_ORGANIZER_DIR: dir, NDF_ORGANIZER_LANE: laneName(inst), NDF_VERSION: inst.NDF_VERSION }; +} + +// 置き場の読み書き。テストはメモリに替える +export interface OrganizerFiles { + write(rel: string, value: unknown): Promise; + // sub の下の .json(名前の順)。読めないものは飛ばす + list(sub: string): Promise<{ name: string; value: Record }[]>; +} + +// 一時ファイルへ書いて名前を付け替える(NDF が書きかけを読まない) +export function fsOrganizerFiles(dir: string): OrganizerFiles { + return { + async write(rel, value) { + const path = join(dir, rel); + await mkdir(dirname(path), { recursive: true }); + const tmp = join(dirname(path), `.tmp-${process.pid}-${Date.now()}`); + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`); + await rename(tmp, path); + }, + async list(sub) { + const names = (await readdir(join(dir, sub)).catch(() => [] as string[])).filter((n) => n.endsWith(".json") && !n.startsWith(".")).sort(); + const out: { name: string; value: Record }[] = []; + for (const name of names) { + try { + const value = JSON.parse(await readFile(join(dir, sub, name), "utf8")) as unknown; + if (value && typeof value === "object" && !Array.isArray(value)) out.push({ name, value: value as Record }); + } catch { + // 読めないファイルは次の読みで拾う + } + } + return out; + }, + }; +} + +export interface OrganizerAnswers { + answers: { request_id: string; result: string; version: string | null; release: string | null; reason: string | null; at: string }[]; + ranges: { seq: number; sha: string | null; version: string | null; prs: number[]; lanes: string[] }[]; +} + +// 答えと範囲を置き場へ書く(answers/<依頼の id>.json・ranges/<連番>.json) +export async function writeAnswers(files: OrganizerFiles, body: OrganizerAnswers): Promise { + for (const a of body.answers) { + await files.write(`answers/${a.request_id}.json`, { result: a.result, version: a.version, release: a.release, reason: a.reason, at: a.at }); + } + for (const r of body.ranges) await files.write(`ranges/${r.seq}.json`, { sha: r.sha, version: r.version, prs: r.prs, lanes: r.lanes }); +} + +// 写した事象(冪等の鍵つき。受け口の照合と同じ鍵) +export interface ReleaseBody { + key: string; + event: Pick; +} + +const ints = (v: unknown): number[] => (Array.isArray(v) ? v.filter((x): x is number => Number.isInteger(x) && x >= 1) : []); +const str = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null); + +export function requestedFrom(value: Record): ReleaseRequestedPayload | null { + const id = str(value.id); + const lane = str(value.lane); + const version = str(value.ndf_version); + const at = str(value.at); + if (!id || !lane || !version || !at || value.kind !== "request") return null; + return { request_id: id, lane, prs: ints(value.prs), issues: ints(value.issues), ndf_version: version, requested_at: at }; +} + +export function presentedFrom(value: Record): ReleasePresentedPayload | null { + const sha = str(value.sha); + const version = str(value.version); + if (!Number.isInteger(value.seq) || (value.k !== 1 && value.k !== 2) || !sha || !version || typeof value.auto !== "boolean") return null; + const lanes = Array.isArray(value.lanes) + ? (value.lanes as Record[]).filter((l) => typeof l?.lane === "string").map((l) => ({ lane: l.lane as string, requested: l.requested === true })) + : []; + return { seq: value.seq as number, k: value.k, sha, version, prs: ints(value.prs), lanes, added_prs: ints(value.added_prs), auto: value.auto }; +} + +export function progressFrom(value: Record): ReleaseProgressPayload[] { + if (!Number.isInteger(value.seq) || !Array.isArray(value.stages)) return []; + return (value.stages as unknown[]) + .filter((s): s is ReleaseProgressPayload["stage"] => typeof s === "string" && STAGES.has(s)) + .map((stage) => ({ seq: value.seq as number, stage })); +} + +export function settledFrom(value: Record): ReleaseSettledPayload | null { + if (!Number.isInteger(value.seq) || (value.result !== "released" && value.result !== "failed")) return null; + const out: ReleaseSettledPayload = { + seq: value.seq as number, + result: value.result, + prs: ints(value.prs), + lanes: Array.isArray(value.lanes) ? (value.lanes as unknown[]).filter((l): l is string => typeof l === "string") : [], + }; + const version = str(value.version); + const url = str(value.prod_pr_url); + const sha = str(value.sha); + const reason = str(value.reason); + if (version) out.version = version; + if (url) out.prod_pr_url = url; + if (sha) out.sha = sha; + if (reason) out.reason = reason.slice(0, 4000); + return out; +} + +// 答えの無い依頼(再送の対象。同じ鍵で送るため受け口で 1 件になる。I59) +export async function openRequests(files: OrganizerFiles): Promise { + const answered = new Set((await files.list("answers")).map((a) => a.name.replace(/\.json$/, ""))); + return (await files.list("requests")) + .map((r) => requestedFrom(r.value)) + .filter((p): p is ReleaseRequestedPayload => p !== null && !answered.has(p.request_id)); +} + +// 置き場のファイルを進捗の事象へ写す。依頼は答えの無いものだけ、配布の実行の事象は配布の実行のジョブだけ(実行の識別子が鍵の頭) +export async function releaseBodies(files: OrganizerFiles, inst: Pick): Promise { + const out: ReleaseBody[] = (await openRequests(files)).map((p) => ({ key: p.request_id, event: { type: "release.requested", payload: p } })); + if (inst.START !== "release") return out; + const run = inst.SPRINT_RUN_ID; + for (const f of await files.list("presented")) { + const p = presentedFrom(f.value); + if (p) out.push({ key: releasePresentedKey(run, p.k), event: { type: "release.presented", payload: p } }); + } + for (const f of await files.list("progress")) { + for (const p of progressFrom(f.value)) out.push({ key: releaseProgressKey(run, p.stage), event: { type: "release.progress", payload: p } }); + } + for (const f of await files.list("results")) { + const p = settledFrom(f.value); + if (p) out.push({ key: releaseSettledKey(run, p.result), event: { type: "release.settled", payload: p } }); + } + return out; +} + +// 配布の実行の conductor への最初の指示(NDF の development-workflow の references/parallel.md の「配布の実行」) +export const RELEASE_PROMPT = "/ndf:development-workflow --parallel-helper --external"; + +// 手動の実行の再開で、答えの出た配布の依頼を conductor へ渡す指示 +export function releaseAnswerDirective(requestId: string): string { + return `配布の依頼 ${requestId} の答えが置き場(NDF_ORGANIZER_DIR)に届いた。parallel.py await --external で読み、続きを進めてください`; +} diff --git a/factory/runner/src/redact.ts b/factory/runner/src/redact.ts new file mode 100644 index 000000000..dd5249b9f --- /dev/null +++ b/factory/runner/src/redact.ts @@ -0,0 +1,28 @@ +// 外へ送る本文(作業の報告・ログ・判断の文・ファイルのパス)から秘密を伏せる(#2172 の決定 7)。 +// 形の規則は NDF の plugins/ndf/scripts/lib/secret_redact.py の 4 つを写したもの(factory/ から plugins/ を参照しないため写す)。 +// 規則を変えるときは両方を直す。行と字の数の切り詰め(redact_output の 5 行・500 字)は写さない +export const MASK = "***"; + +const PATTERNS: [RegExp, string][] = [ + // URL の資格情報(https://user:pass@host・https://x-access-token:@host・https://@host) + [/(?<=:\/\/)[^\s/@]+@/g, `${MASK}@`], + // GitHub の token の形 + [/\b(?:gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,})/g, MASK], + // x-access-token:<値> が URL の外に現れたとき + [/(?<=x-access-token:)[^\s@]+/g, MASK], + // 認証ヘッダー(Authorization: Bearer <値>・Proxy-Authorization: ...)は値ごと伏せる + [/\b((?:proxy-)?authorization\s*[:=]\s*)[^\r\n]+/gi, `$1${MASK}`], +]; + +// 短すぎる値は置き換えない(ありふれた語まで伏せないため) +const MIN_SECRET_LENGTH = 8; + +// secrets はランナーが持つ秘密の値(実行ジョブの token・導入の token・持ち込んだキー・引き受けたロールの資格)。形に頼らず文字列のまま伏せる +export function redact(text: string, secrets: readonly string[] = []): string { + let out = text; + for (const value of [...secrets].filter((s) => s.length >= MIN_SECRET_LENGTH).sort((a, b) => b.length - a.length)) { + out = out.split(value).join(MASK); + } + for (const [pattern, repl] of PATTERNS) out = out.replace(pattern, repl); + return out; +} diff --git a/factory/runner/src/runner.test.ts b/factory/runner/src/runner.test.ts index 3466285d7..da04af80b 100644 --- a/factory/runner/src/runner.test.ts +++ b/factory/runner/src/runner.test.ts @@ -4,9 +4,9 @@ import { GITHUB_TOKEN_REFRESH_MS, runJob, type JobRuntime } from "./main.ts"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { claudeEnv, ROLE_PROFILE, type AssumeRole } from "./model.ts"; +import { claudeEnv, containerRoleSecrets, passedModelSecrets, ROLE_PROFILE, type AssumeRole } from "./model.ts"; import { createSender } from "./send.ts"; -import { trackedEntries } from "./state.ts"; +import { packState, trackedEntries } from "./state.ts"; import { common, delegatedFromRecord, @@ -126,6 +126,21 @@ describe("状態(受け入れ条件 28・32、I42)", () => { expect(trackedEntries([".ndf/tmp/x", ".ndf/pace.json"], tracked)).toEqual([".ndf/pace.json"]); expect(trackedEntries(["src/a.ts"], tracked)).toEqual(["src/a.ts"]); }); + + it("配布の提示が作った追跡しない承認資料も保存し、復元で受け入れる。追跡する承認資料とほかの issues/ は断る", () => { + const approval = "issues/approval-ndf-v1.2.0-parallel-3-2.md"; + expect(trackedEntries([".ndf/tmp/x", approval], new Set())).toEqual([]); + expect(trackedEntries([approval], new Set([approval]))).toEqual([approval]); + expect(trackedEntries(["issues/approval-ndf-v1.2.0.md", "issues/x.md"], new Set())).toEqual(["issues/approval-ndf-v1.2.0.md", "issues/x.md"]); + const calls: string[][] = []; + const run = (cmd: string, args: string[]) => { + calls.push([cmd, ...args]); + if (cmd === "test" && args[1] !== ".ndf/tmp") throw new Error("無い"); + return cmd === "git" ? `${approval}\0issues/memo.md\0` : ""; + }; + packState("/r", "/a.tgz", run); + expect(calls.at(-1)).toEqual(["tar", "-czf", "/a.tgz", "--", ".ndf/tmp", approval]); + }); }); describe("claude の呼び方(受け入れ条件 8・29)", () => { @@ -150,6 +165,26 @@ describe("claude の呼び方(受け入れ条件 8・29)", () => { expect(env).toMatchObject({ ANTHROPIC_API_KEY: "mine", AWS_ACCESS_KEY_ID: "AK" }); expect(env.INGEST_TOKEN).toBeUndefined(); expect(env.MODEL_CREDENTIAL_SECRET_ARN).toBeUndefined(); + // 子へそのまま渡した資格は、外へ送る工程の中身から伏せる値に入る + const creds = { ...base, ANTHROPIC_API_KEY: "mine", AWS_SECRET_ACCESS_KEY: "SK" }; + expect(passedModelSecrets(creds, "local")).toEqual(["mine", "SK"]); + expect(passedModelSecrets(creds, "managed")).toEqual([]); + }); + + it("managed はコンテナの資格の口から子が使うタスクの役割の資格を読み、伏せる値に足す。ほかの形態と口の無いときは空", async () => { + const urls: string[] = []; + const get = (async (u: string, init?: RequestInit) => { + urls.push(`${u} ${new Headers(init?.headers).get("authorization") ?? ""}`); + return new Response(JSON.stringify({ AccessKeyId: "ASIA", SecretAccessKey: "SK", Token: "TK", Expiration: AT })); + }) as typeof fetch; + const relative = { ...base, AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/v2/credentials/x" }; + expect(await containerRoleSecrets(relative, "managed", get)).toEqual(["ASIA", "SK", "TK"]); + const full = { ...base, AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://127.0.0.1/creds", AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE: "/t" }; + expect(await containerRoleSecrets(full, "managed", get, async () => "auth\n")).toEqual(["ASIA", "SK", "TK"]); + expect(urls).toEqual(["http://169.254.170.2/v2/credentials/x ", "http://127.0.0.1/creds auth"]); + expect(await containerRoleSecrets(relative, "byok", get)).toEqual([]); + expect(await containerRoleSecrets(base, "managed", get)).toEqual([]); + expect(await containerRoleSecrets(relative, "managed", (async () => new Response("", { status: 500 })) as typeof fetch)).toEqual([]); }); it("byok の API キーは子の環境にだけ置き、ロールは外部 ID つきで引き受ける。外部 ID が違えば失敗する", async () => { diff --git a/factory/runner/src/runtime.ts b/factory/runner/src/runtime.ts index c255005e2..33d98e374 100644 --- a/factory/runner/src/runtime.ts +++ b/factory/runner/src/runtime.ts @@ -3,14 +3,16 @@ import { spawn } from "node:child_process"; import { mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import type { RunJobInstruction } from "@ndf-factory/contracts"; import type { JobRuntime } from "./main.ts"; -import { claudeEnv, ROLE_REFRESH_MS, stsAssumeRole, writeRoleCredentials, type ModelCredential } from "./model.ts"; +import { claudeEnv, containerRoleSecrets, passedModelSecrets, ROLE_REFRESH_MS, stsAssumeRole, writeRoleCredentials, type AssumeRole, type ModelCredential } from "./model.ts"; import { parseLoopFile } from "./loop-file.ts"; +import { fsOrganizerFiles, ORGANIZER_DIR, organizerEnv, RELEASE_PROMPT } from "./organizer.ts"; import { runSections, sectionLimitOf, type SectionRun } from "./sections.ts"; import { exec, packState, statePaths, unpackState } from "./state.ts"; +import { collectStepDetails, readPlan, type GitExec } from "./step-details.ts"; import { delegatedFromRecord, issueRequestFromLine, @@ -80,6 +82,8 @@ export function loopPrompt(inst: RunJobInstruction): string { export function firstPrompt(inst: RunJobInstruction, directive: string | null): string { if (inst.START === "new") return `/ndf:development-workflow ${inst.ISSUES!.split(",").map((n) => `#${n}`).join(" ")}`; if (inst.START === "loop") return loopPrompt(inst); + // 配布の実行を判断への答えで再開したときは、答えの本文を添える(添えないと同じプランを答え無しで流し直して同じ問いで止まる) + if (inst.START === "release") return directive ? `${RELEASE_PROMPT}\n\n利用者の判断: ${directive}` : RELEASE_PROMPT; return `前回の続きから進めてください。${directive ? `利用者の判断: ${directive}` : ""}`; } @@ -108,7 +112,21 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< const issueRequests = join(jobHome, "issue-requests.jsonl"); jobEnv.NDF_ISSUE_REQUEST_FILE = issueRequests; const sh = (cmd: string, args: string[], opts: { cwd: string; env?: Record }) => run(cmd, args, { env: jobEnv, ...opts }); - const childEnv = () => claudeEnv(jobEnv, instruction.EXECUTION_MODE, credential, stsAssumeRole, sessionName, credentialDir); + // 引き受けたロールの資格は発行のたびに控える。更新の前の値も期限まで有効なため、伏せる値から外さない + const assumedSecrets: string[] = []; + const assumeRole: AssumeRole = async (cred, name) => { + const c = await stsAssumeRole(cred, name); + assumedSecrets.push(c.accessKeyId, c.secretAccessKey, c.sessionToken); + return c; + }; + // managed で子が使うタスクの役割の資格も、読んだ値を控える。更新の前の値も期限まで有効なため外さない + const noteContainerRole = async () => { + for (const v of await containerRoleSecrets(jobEnv, instruction.EXECUTION_MODE)) if (!assumedSecrets.includes(v)) assumedSecrets.push(v); + }; + const childEnv = async () => { + await noteContainerRole(); + return claudeEnv(jobEnv, instruction.EXECUTION_MODE, credential, assumeRole, sessionName, credentialDir); + }; // 版を固定して取った取得元。スクリプトは入れた版と同じこの写しから読む const ndfSource = join(work, "ndf-marketplace"); const ndfScripts = () => join(ndfSource, "plugins/ndf/scripts"); @@ -118,9 +136,41 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< const paths = statePaths(instruction.LOOP_NAME); // 同じリポジトリの別の実行が持つ課題を、NDF の loop select がその周回の選定から外す(決定 3) if (instruction.EXCLUDE_ISSUES) jobEnv.NDF_LOOP_EXCLUDE = instruction.EXCLUDE_ISSUES; + // Factory のオーガナイザーの置き場(#2439 の I68)。1 台の中のオーガナイザーを起動させず、本番の配布は依頼として出させる + const organizerDir = join(repoDir, ORGANIZER_DIR); + Object.assign(jobEnv, organizerEnv(instruction, organizerDir)); + // 外へ送る工程の中身から伏せる秘密の値(実行ジョブの token・導入の token・持ち込んだキー・引き受けたロールとタスクの役割の資格。#2172 の決定 7) + const githubTokens: string[] = []; + const secretValues = async (): Promise => { + const out = [token, ...githubTokens, jobEnv.INGEST_TOKEN ?? ""]; + // local は利用者が手元で設定したモデルの資格をそのまま子へ渡すため、その値も伏せる + out.push(...passedModelSecrets(jobEnv, instruction.EXECUTION_MODE)); + if (credential?.kind === "anthropic_api_key") out.push(credential.api_key); + await noteContainerRole(); + out.push(...assumedSecrets); + return out.filter(Boolean); + }; + const git: GitExec = async (args, cwd) => { + let stdout = ""; + const code = await run("git", args, { cwd, env: jobEnv, onLine: (line) => (stdout += `${line}\n`), onStderr: () => undefined }); + return { code, stdout }; + }; + const ledgerRows = async () => + (await readFile(askLedger, "utf8").catch(() => "")) + .split("\n") + .filter(Boolean) + .flatMap((raw) => { + try { + return [JSON.parse(raw) as Record]; + } catch { + return []; + } + }); return { + organizer: fsOrganizerFiles(organizerDir), async writeGitHubToken(token) { + githubTokens.push(token); const dir = jobEnv.GH_CONFIG_DIR!; await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(join(dir, "hosts.yml"), `github.com:\n oauth_token: ${token}\n git_protocol: https\n`, { mode: 0o600 }); @@ -202,8 +252,9 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< // このジョブの記録だけを送る(ask.jsonl は利用者の全プロジェクトで共有される)。cwd は realpath で書かれる const root = await realpath(work); const ownRow = (row: Record) => typeof row.cwd === "string" && (row.cwd === root || row.cwd.startsWith(`${root}/`)); - // 再開では復元したログの末尾から読む。前のジョブが送った行を新しい job_id で送り直さない - if (inst.START === "resume") { + // 再開と配布の実行(承認・出し直し・続きも START は release のまま)では復元したログの末尾から読む。 + // 前のジョブが送った工程と使用量を新しい job_id で送り直さない + if (inst.START === "resume" || inst.START === "release") { for (const file of [...(await findFiles(join(repoDir, ".ndf"), "progress.jsonl")), askLedger]) { offsets.set(file, (await readFile(file, "utf8").catch(() => "")).length); } @@ -219,17 +270,22 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< const content = await readFile(file, "utf8").catch(() => ""); const from = offsets.get(file) ?? 0; offsets.set(file, content.length); + const stateDir = dirname(file); + const name = basename(stateDir).replace(/-state$/, ""); + const info = await readPlan(stateDir); for (const raw of content.slice(from).split("\n").filter(Boolean)) { const line = JSON.parse(raw) as Record; - const name = file.split("/").slice(-2, -1)[0]!.replace(/-state$/, ""); if (name !== plan) { plan = name; - await emit(phaseChanged(name)); + await emit(phaseChanged(name, info.phase)); } - const body = translateProgressLine(line, inst.REPO); - if (body) await emit(body); + const details = line.kind === "step" ? await collectStepDetails({ git, ledgerRows, secrets: secretValues }, name, stateDir, line, info) : {}; + const body = translateProgressLine(line, inst.REPO, details); + // 同じ行の step.recorded と usage.recorded は同じ at で送り、工程の費用を結ぶ(決定 8) + const at = new Date().toISOString(); + if (body) await emit(body, at); // supervise が別のプロセスで起動したステップの使用量(conductor の result には入らない) - for (const u of usageFromStepLine(line)) await emit(u); + for (const u of usageFromStepLine(line)) await emit(u, at); } } // 代わりの判断の記録(ask_ledger.py の ask.jsonl の kind: decision の行) @@ -271,7 +327,7 @@ export async function createRuntime(instruction: RunJobInstruction, env: Record< // ロールの資格は 1 時間で切れるため、動いている間は置き直す(子は credential_process で読み直す) const role = credential?.kind === "aws_role" ? credential : null; const refresher = role - ? setInterval(() => void writeRoleCredentials(role, stsAssumeRole, sessionName, credentialDir).catch(() => undefined), ROLE_REFRESH_MS) + ? setInterval(() => void writeRoleCredentials(role, assumeRole, sessionName, credentialDir).catch(() => undefined), ROLE_REFRESH_MS) : null; // 区間ごとに claude -p を 1 回起動する。使用量(usage.recorded)は区間ごとに今のとおり送る const launch = async (prompt: string): Promise => { diff --git a/factory/runner/src/state.ts b/factory/runner/src/state.ts index 3279c0ecf..81911ddbc 100644 --- a/factory/runner/src/state.ts +++ b/factory/runner/src/state.ts @@ -1,8 +1,14 @@ -// 状態の保存と復元(受け入れ条件 28・32、I42)。保存は NDF の状態ディレクトリ(追跡しないパス)だけを tar にし、 +// 状態の保存と復元(受け入れ条件 28・32、I42)。保存は NDF の状態ディレクトリと配布の承認資料(追跡しないパス)だけを tar にし、 // 復元は追跡するパスを 1 つでも含む保存を捨てる。取り込んだリポジトリの .ndf/ を上書きしない import { execFileSync } from "node:child_process"; -export const STATE_PATHS = [".ndf/tmp", ".ndf/loop"]; +// .ndf/organizer は Factory のオーガナイザーとの置き場(#2439)。依頼と答えを周回と再開をまたいで残す +export const STATE_PATHS = [".ndf/tmp", ".ndf/loop", ".ndf/organizer"]; + +// 名前で決まる状態のファイル。配布の提示のプランが作る承認資料(parallel_release.py の plan_parallel_release が +// issues/approval-<プラグイン>-v<版>-parallel-<連番>-<提示>.md に書く)は、承認の後の新しいコンテナで confirm のプランが +// --from approve で読むため、追跡していなければ保存と復元の対象にする +export const STATE_FILE_PATTERNS = [/^issues\/approval-[^/]+-parallel-\d+-\d+\.md$/]; // 保存するパス。開発ループの実行は、そのループの引継ぎ文書(.ndf/handoff/loop-<名>.md)を 1 つ足す(#2347) export function statePaths(loopName?: string | null): string[] { @@ -18,7 +24,7 @@ export function trackedFiles(repoDir: string, run: Exec = exec): Set { // 保存の中のパスのうち、追跡するパスか、状態ディレクトリの外に当たるもの export function trackedEntries(entries: string[], tracked: Set, paths: string[] = STATE_PATHS): string[] { - const inState = (e: string) => paths.some((s) => e === s || e.startsWith(`${s}/`)); + const inState = (e: string) => paths.some((s) => e === s || e.startsWith(`${s}/`)) || STATE_FILE_PATTERNS.some((p) => p.test(e)); return entries .map((e) => e.replace(/^\.\//, "").replace(/\/$/, "")) .filter((e) => e !== "" && (tracked.has(e) || !inState(e))); @@ -34,7 +40,18 @@ export class StateRejectedError extends Error { } } -// 保存する。追跡しない状態ディレクトリだけを tar にする +// 名前で決まる状態のファイルのうち、追跡しないもの(無視するファイルも含む) +export function untrackedStateFiles(repoDir: string, run: Exec = exec): string[] { + try { + return run("git", ["ls-files", "--others", "-z", "--", "issues"], repoDir) + .split("\0") + .filter((f) => STATE_FILE_PATTERNS.some((p) => p.test(f))); + } catch { + return []; + } +} + +// 保存する。追跡しない状態ディレクトリと、名前で決まる追跡しない状態のファイルだけを tar にする export function packState(repoDir: string, archive: string, run: Exec = exec, paths: string[] = STATE_PATHS): void { const present = paths.filter((p) => { try { @@ -44,6 +61,7 @@ export function packState(repoDir: string, archive: string, run: Exec = exec, pa return false; } }); + present.push(...untrackedStateFiles(repoDir, run)); run("tar", ["-czf", archive, "--", ...(present.length > 0 ? present : ["--files-from=/dev/null"])], repoDir); } diff --git a/factory/runner/src/step-details.test.ts b/factory/runner/src/step-details.test.ts new file mode 100644 index 000000000..fa2520faf --- /dev/null +++ b/factory/runner/src/step-details.test.ts @@ -0,0 +1,159 @@ +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { validateProgressEvent, type ProgressEvent, type StepRecordedEvent } from "@ndf-factory/contracts"; +import { redact } from "./redact.ts"; +import { collectStepDetails, parseNumstat, type GitExec } from "./step-details.ts"; +import { common, fitStep, MAX_EVENT_BYTES, phaseChanged, phaseNameOf, translateProgressLine } from "./translate.ts"; + +const ID = { sprintRunId: "sr1", jobId: "job1", repo: "org-a/repo-a" }; +const AT = "2026-11-04T09:02:11.000Z"; +const full = (body: Pick) => ({ ...common(ID, 1, AT), ...body }) as ProgressEvent; +const SHA = (c: string) => c.repeat(40); + +describe("フェーズの名前の写し(受け入れ条件 4・決定 1)", () => { + it("プランの フェーズ の 6 つの値を 5 つの名前へ写し、表に無い値・読めないときは計画の名前を送る", () => { + const cases: [string, string][] = [ + ["設計", "設計"], + ["実装", "実装"], + ["検査", "検査"], + ["配布(開発版)", "取り込み"], + ["配布(本番)", "仕上げ"], + ["まとめ", "仕上げ"], + ]; + for (const [plan, name] of cases) expect(phaseNameOf("7-release", plan)).toBe(name); + expect(phaseNameOf("8-release-request", "配布の依頼")).toBe("release-request"); + expect(phaseNameOf("5-impl-2172", null)).toBe("impl"); + const changed = phaseChanged("5-impl-2172", "実装"); + expect(changed.payload).toEqual({ phase: "実装", issues: [2172] }); + expect(validateProgressEvent(full(changed)).ok).toBe(true); + }); +}); + +describe("伏せ字(I3・決定 7)", () => { + it("NDF の 4 つの形と、ランナーが持つ秘密の値を *** にする", () => { + const text = [ + "push https://x-access-token:abcdEFGH1234@github.com/o/r.git", + "token ghs_ABCDEFGHijklmnop1234", + "x-access-token:zzzzzzzzzz に失敗", + "Authorization: Bearer eyJhbGciOi.secret", + "key sk-ant-test-SECRET-0001 を読んだ", + ].join("\n"); + const out = redact(text, ["sk-ant-test-SECRET-0001", "short"]); + for (const leaked of ["abcdEFGH1234", "ghs_ABCDEFGHijklmnop1234", "zzzzzzzzzz", "eyJhbGciOi.secret", "sk-ant-test-SECRET-0001"]) { + expect(out).not.toContain(leaked); + } + expect(out).toContain("https://***@github.com/o/r.git"); + expect(out).toContain("Authorization: ***"); + }); +}); + +describe("事象 1 件の上限(I2)", () => { + // 4 バイトの文字で、定義の上限の内のまま 300 KiB を超える + it("300 KiB になる工程の中身を 256 KiB 以下に削り、output.truncated を真にし、契約に合う", () => { + const payload: StepRecordedEvent["payload"] = { + step: "impl", + exit_code: 0, + seconds: 1, + summary: "s", + output: { kind: "report", text: "あ".repeat(10_000), truncated: false }, + files: Array.from({ length: 100 }, (_, i) => ({ path: `${"𠀋".repeat(500)}${i}`, added: 1, deleted: 0 })), + decisions: Array.from({ length: 20 }, () => ({ question: "𠀋".repeat(1000), answer: "𠀋".repeat(1000), by: "ai" as const })), + }; + expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(300 * 1024); + const fitted = fitStep(payload); + const event = full({ type: "step.recorded", payload: fitted }); + expect(Buffer.byteLength(JSON.stringify(event))).toBeLessThanOrEqual(MAX_EVENT_BYTES); + expect(fitted.output!.truncated).toBe(true); + expect(validateProgressEvent(event).ok).toBe(true); + }); + + it("上限に収まる工程はそのまま送る", () => { + const payload: StepRecordedEvent["payload"] = { step: "impl", exit_code: 0, seconds: 1, summary: "s", files: [] }; + expect(fitStep(payload)).toEqual(payload); + }); +}); + +describe("工程の中身の収集(決定 3〜6)", () => { + const setup = () => { + const root = mkdtempSync(join(tmpdir(), "step-details-")); + const workdir = join(root, "wt"); + mkdirSync(workdir); + const stateDir = join(root, "5-impl-2172-state"); + mkdirSync(stateDir); + writeFileSync(join(root, "5-impl-2172.json"), JSON.stringify({ フェーズ: "実装", 作業場所: workdir })); + writeFileSync(join(stateDir, "01-impl.out"), "## 作業の報告\n- 結果: 完了\ntoken ghs_ABCDEFGHijklmnop1234\n"); + return { root, workdir, stateDir }; + }; + const line = { kind: "step", at: AT, step: "impl", type: "work", exit: 0, seconds: 60, summary: "済んだ", out: "01-impl.out" }; + + it("プランのフェーズ・計画の名前の課題・時間の窓のコミットのファイル・作業場所の判断・作業の報告を集め、伏せて送る", async () => { + const { workdir, stateDir } = setup(); + const calls: { args: string[]; cwd: string }[] = []; + const git: GitExec = async (args, cwd) => { + calls.push({ args, cwd }); + return { code: 0, stdout: [`@${SHA("b")} ${SHA("a")}`, "", "3\t1\tfactory/a.ts", "-\t-\timg.png", `@${SHA("a")} ${SHA("9")}`, "", "2\t0\tfactory/a.ts", ""].join("\n") }; + }; + const ledgerRows = async () => [ + { kind: "decision", cwd: `${workdir}/sub`, at: "2026-11-04T09:01:40Z", question: "どちらにする", answer: "A にする" }, + { kind: "decision", cwd: "/elsewhere", at: "2026-11-04T09:01:40Z", question: "別の作業場所", answer: "x" }, + { kind: "decision", cwd: workdir, at: "2026-11-04T08:00:00Z", question: "窓の外", answer: "x" }, + ]; + const details = await collectStepDetails({ git, ledgerRows, secrets: async () => [] }, "5-impl-2172", stateDir, line); + expect(calls[0]!.cwd).toBe(workdir); + expect(calls[0]!.args).toContain("--since=2026-11-04T09:01:11+00:00"); + expect(calls[0]!.args).toContain("--until=2026-11-04T09:02:11+00:00"); + expect(details).toMatchObject({ + phase: "実装", + issues: [2172], + files: [ + { path: "factory/a.ts", added: 5, deleted: 1 }, + { path: "img.png", added: null, deleted: null }, + ], + commit_range: { from: SHA("9"), to: SHA("b") }, + decisions: [{ question: "どちらにする", answer: "A にする", by: "ai" }], + output: { kind: "report", truncated: false }, + }); + expect(details.output!.text).not.toContain("ghs_ABCDEFGHijklmnop1234"); + const body = translateProgressLine(line, ID.repo, details)!; + expect(validateProgressEvent(full(body)).ok).toBe(true); + }); + + it("作業場所が無い・git が 0 以外で終わると、ファイルとコミットの範囲を省く。run のステップはログ", async () => { + const { stateDir } = setup(); + const git: GitExec = async () => ({ code: 128, stdout: "" }); + const details = await collectStepDetails({ git, ledgerRows: async () => [], secrets: async () => [] }, "5-impl-2172", stateDir, { ...line, type: "run" }); + expect(details.files).toBeUndefined(); + expect(details.commit_range).toBeUndefined(); + expect(details.output!.kind).toBe("log"); + }); + + it("プランの JSON が読めないときはフェーズに計画の名前を入れ、スプリントで 1 つの計画は課題を持たない", async () => { + const root = mkdtempSync(join(tmpdir(), "step-details-")); + const stateDir = join(root, "6-check-state"); + mkdirSync(stateDir); + const details = await collectStepDetails({ git: async () => ({ code: 0, stdout: "" }), ledgerRows: async () => [], secrets: async () => [] }, "6-check", stateDir, line); + expect(details).toEqual({ phase: "check" }); + }); + + it("出力の参照が状態のディレクトリの外(../・絶対パス・外を指すシンボリックリンク)なら出力を省く", async () => { + const root = mkdtempSync(join(tmpdir(), "step-details-")); + const stateDir = join(root, "6-check-state"); + mkdirSync(stateDir); + writeFileSync(join(root, "secret.pem"), "PRIVATE KEY"); + symlinkSync(join(root, "secret.pem"), join(stateDir, "01-link.out")); + writeFileSync(join(stateDir, "02-check.out"), "通った"); + const deps = { git: async () => ({ code: 0, stdout: "" }), ledgerRows: async () => [], secrets: async () => [] }; + for (const out of ["../secret.pem", join(root, "secret.pem"), "01-link.out"]) { + const details = await collectStepDetails(deps, "6-check", stateDir, { ...line, out }); + expect(details.output).toBeUndefined(); + } + const inside = await collectStepDetails(deps, "6-check", stateDir, { ...line, out: "02-check.out" }); + expect(inside.output!.text).toBe("通った"); + }); + + it("コミットの無い工程は空の配列で、コミットの範囲を持たない", () => { + expect(parseNumstat("")).toEqual({ files: [], files_omitted: 0 }); + }); +}); diff --git a/factory/runner/src/step-details.ts b/factory/runner/src/step-details.ts new file mode 100644 index 000000000..68b14f650 --- /dev/null +++ b/factory/runner/src/step-details.ts @@ -0,0 +1,162 @@ +// 工程の中身の収集(#2172 の決定 3〜7)。NDF のプランの JSON・作業場所の git・判断の台帳(ask.jsonl)・ステップの出力(.out)から、 +// step.recorded に添える項目を集める。外のファイルとコマンドに触るのはここだけで、集められなかった項目は省く(工程の送信を止めない) +import { readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { redact } from "./redact.ts"; +import { cutUtf8, phaseNameOf, phaseOf, type StepDetails } from "./translate.ts"; + +export const MAX_FILES = 100; +export const MAX_DECISIONS = 20; +export const MAX_DECISION_CHARS = 1000; +export const MAX_PATH_CHARS = 512; +export const MAX_OUTPUT_BYTES = 32_768; + +export type GitExec = (args: string[], cwd: string) => Promise<{ code: number; stdout: string }>; + +export interface StepDetailsDeps { + git: GitExec; + // 判断の台帳の行(kind: decision。cwd・at・question・answer を持つ) + ledgerRows: () => Promise[]>; + // 送る前に伏せる秘密の値(その時点の値) + secrets: () => Promise; +} + +export interface PlanInfo { + // プランの JSON の "フェーズ"(読めなければ null) + phase: string | null; + // プランの JSON の "作業場所"(読めなければ null) + workdir: string | null; +} + +type Line = Record; + +// 状態のディレクトリ(<計画の名前>-state)の隣のプランの JSON(<計画の名前>.json) +export async function readPlan(stateDir: string): Promise { + const text = await readFile(`${stateDir.replace(/-state\/?$/, "")}.json`, "utf8").catch(() => null); + if (text === null) return { phase: null, workdir: null }; + try { + const json = JSON.parse(text) as Record; + const str = (v: unknown) => (typeof v === "string" && v !== "" ? v : null); + return { phase: str(json["フェーズ"]), workdir: str(json["作業場所"]) }; + } catch { + return { phase: null, workdir: null }; + } +} + +// 工程の時間の窓(at − seconds 〜 at)。at が読めなければ null +export function stepWindow(line: Line): { since: Date; until: Date } | null { + const until = typeof line.at === "string" ? new Date(line.at) : null; + if (!until || Number.isNaN(until.getTime())) return null; + const seconds = typeof line.seconds === "number" && line.seconds >= 0 ? line.seconds : 0; + return { since: new Date(until.getTime() - seconds * 1000), until }; +} + +// git の --since / --until は秒の精度で受ける +const gitTime = (d: Date, round: "floor" | "ceil") => { + const ms = round === "floor" ? Math.floor(d.getTime() / 1000) * 1000 : Math.ceil(d.getTime() / 1000) * 1000; + return `${new Date(ms).toISOString().slice(0, 19)}+00:00`; +}; + +const SHA = /^[0-9a-f]{40}$/; + +// git log --numstat の出力から、触ったファイル(パスごとの和)と、最初の親・最後のコミットを読む +export function parseNumstat(stdout: string): Pick { + const byPath = new Map(); + const commits: { sha: string; parent: string | null }[] = []; + for (const raw of stdout.split("\n")) { + const line = raw.trimEnd(); + if (line.startsWith("@")) { + const [sha, parent] = line.slice(1).split(" "); + if (sha && SHA.test(sha)) commits.push({ sha, parent: parent && SHA.test(parent) ? parent : null }); + continue; + } + const m = /^(-|\d+)\t(-|\d+)\t(.+)$/.exec(line); + if (!m) continue; + const num = (v: string) => (v === "-" ? null : Number(v)); + const prev = byPath.get(m[3]!); + const add = (a: number | null, b: number | null) => (a === null || b === null ? null : a + b); + byPath.set(m[3]!, prev ? { added: add(prev.added, num(m[1]!)), deleted: add(prev.deleted, num(m[2]!)) } : { added: num(m[1]!), deleted: num(m[2]!) }); + } + const all = [...byPath.entries()].map(([path, n]) => ({ path, ...n })); + const files = all.slice(0, MAX_FILES); + const out: Pick = { files, files_omitted: all.length - files.length }; + // git log は新しい順。最後のコミットが先頭、最初のコミットが末尾 + const last = commits[0]; + const first = commits.at(-1); + if (last && first?.parent) out.commit_range = { from: first.parent, to: last.sha }; + return out; +} + +const isUnder = (path: string, dir: string) => path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`); + +// 判断の台帳の行のうち、cwd が作業場所の下にあり、at が工程の窓に入るもの(決定 5)。文は伏せてから上限で切る +export function decisionsOf( + rows: Line[], + workdir: string, + window: { since: Date; until: Date }, + clean: (text: string) => string = (t) => t, +): NonNullable { + const out: NonNullable = []; + for (const row of rows) { + if (row.kind !== "decision" || typeof row.cwd !== "string" || !isUnder(row.cwd, workdir)) continue; + const at = typeof row.at === "string" ? new Date(row.at).getTime() : Number.NaN; + if (Number.isNaN(at) || at < window.since.getTime() || at > window.until.getTime()) continue; + const text = (v: unknown) => (typeof v === "string" && v !== "" ? Array.from(clean(v)).slice(0, MAX_DECISION_CHARS).join("") : null); + const question = text(row.question); + const answer = text(row.answer); + if (question && answer) out.push({ question, answer, by: "ai" }); + if (out.length >= MAX_DECISIONS) break; + } + return out; +} + +type Changes = Pick; + +// 工程の窓に作業場所で作られたコミットの差分(決定 3・4)。git log が失敗したら空(項目を省く) +async function changesOf(deps: StepDetailsDeps, workdir: string, window: { since: Date; until: Date }, clean: (text: string) => string): Promise { + const args = ["log", "--no-merges", `--since=${gitTime(window.since, "floor")}`, `--until=${gitTime(window.until, "ceil")}`, "--numstat", "--format=@%H %P"]; + const res = await deps.git(args, workdir).catch(() => null); + if (!res || res.code !== 0) return {}; + const parsed = parseNumstat(res.stdout); + const out: Changes = { files: parsed.files!.map((f) => ({ ...f, path: Array.from(clean(f.path)).slice(0, MAX_PATH_CHARS).join("") })) }; + if (parsed.files_omitted) out.files_omitted = parsed.files_omitted; + if (parsed.commit_range) out.commit_range = parsed.commit_range; + return out; +} + +// 状態のディレクトリの中の出力ファイルの実パス。../ や絶対パス、外を指すシンボリックリンクは null(外のファイルを送らない) +async function outputPath(stateDir: string, out: string): Promise { + if (out.startsWith("/") || out.split("/").includes("..")) return null; + const [dir, file] = await Promise.all([realpath(stateDir).catch(() => null), realpath(join(stateDir, out)).catch(() => null)]); + return dir && file && file !== dir && isUnder(file, dir) ? file : null; +} + +// ステップの出力(.out)。伏せてから上限のバイト数で切る。読めなければ undefined(項目を省く) +async function outputOf(stateDir: string, line: Line, clean: (text: string) => string): Promise { + if (typeof line.out !== "string" || line.out === "") return undefined; + const path = await outputPath(stateDir, line.out); + if (path === null) return undefined; + const text = await readFile(path, "utf8").catch(() => null); + if (text === null) return undefined; + const cut = cutUtf8(clean(text), MAX_OUTPUT_BYTES); + return { kind: line.type === "run" ? "log" : "report", text: cut.text, truncated: cut.cut }; +} + +// 1 つのステップの行の工程の中身。plan は計画の名前(5-impl-2172)、stateDir はその状態のディレクトリ +export async function collectStepDetails(deps: StepDetailsDeps, plan: string, stateDir: string, line: Line, info?: PlanInfo): Promise { + const { phase, workdir } = info ?? (await readPlan(stateDir)); + const secrets = await deps.secrets().catch(() => [] as string[]); + const clean = (text: string) => redact(text, secrets); + const details: StepDetails = { phase: phaseNameOf(plan, phase) }; + const issues = phaseOf(plan).issues; + if (issues.length > 0) details.issues = issues; + const window = stepWindow(line); + if (workdir && window) { + Object.assign(details, await changesOf(deps, workdir, window, clean)); + const rows = await deps.ledgerRows().catch(() => null); + if (rows) details.decisions = decisionsOf(rows, workdir, window, clean); + } + const output = await outputOf(stateDir, line, clean); + if (output) details.output = output; + return details; +} diff --git a/factory/runner/src/translate.ts b/factory/runner/src/translate.ts index 62ff3b13a..c65e7c0ab 100644 --- a/factory/runner/src/translate.ts +++ b/factory/runner/src/translate.ts @@ -12,6 +12,7 @@ import type { StepRecordedEvent, UsageRecordedEvent, } from "@ndf-factory/contracts"; +import type { PhaseName } from "@ndf-factory/contracts/phases"; export interface JobIdentity { sprintRunId: string; @@ -52,6 +53,22 @@ export function phaseOf(planName: string): { phase: string; issues: number[] } { return { phase, issues }; } +// プランの JSON の "フェーズ" の値からフェーズの名前への写し(#2172 の決定 1)。まとめ(pace: fast / auto の close)は振り返りと棚卸しを持つため仕上げ +const PLAN_PHASES: Readonly> = { + 設計: "設計", + 実装: "実装", + 検査: "検査", + "配布(開発版)": "取り込み", + "配布(本番)": "仕上げ", + まとめ: "仕上げ", +}; + +// フェーズの名前。プランの JSON が読めない(null)・表に無い値は、計画の名前の断片をそのまま返す(画面は「その他」と出す) +export function phaseNameOf(planName: string, planPhase: string | null): string { + if (planPhase !== null && Object.hasOwn(PLAN_PHASES, planPhase)) return PLAN_PHASES[planPhase]!; + return phaseOf(planName).phase; +} + // summary が JSON の文字列なら summary の値を使う function summaryOf(raw: unknown): string { if (typeof raw !== "string") return ""; @@ -66,8 +83,16 @@ function summaryOf(raw: unknown): string { type Line = Record; -// progress.jsonl の 1 行から事象の本体(common を除く)を作る。扱わない行は null -export function translateProgressLine(line: Line, repo: string): Pick | Pick | null { +// 工程の中身(#2172)。どれも任意で、集められなかった項目は持たない +export type StepDetails = Pick; + +// progress.jsonl の 1 行から事象の本体(common を除く)を作る。扱わない行は null。 +// step の行には工程の中身を添え、事象 1 件の上限に収める +export function translateProgressLine( + line: Line, + repo: string, + details: StepDetails = {}, +): Pick | Pick | null { if (line.kind === "step" && typeof line.step === "string") { const summary = summaryOf(line.summary); const payload: StepRecordedEvent["payload"] = { @@ -79,7 +104,8 @@ export function translateProgressLine(line: Line, repo: string): Pick String(v ?? "")).join("\n"), repo); if (pr) payload.pull_request = pr; - return { type: "step.recorded", payload }; + Object.assign(payload, Object.fromEntries(Object.entries(details).filter(([, v]) => v !== undefined))); + return { type: "step.recorded", payload: fitStep(payload) }; } if (line.kind === "attention") { // 取り込み(base-sync)で解けずに止まった行は Factory の語 base_conflict へ写す(#2347 の決定 10)。ほかの行の理由は変えない @@ -91,8 +117,9 @@ export function translateProgressLine(line: Line, repo: string): Pick { - return { type: "phase.changed", payload: phaseOf(planName) }; +// planPhase はプランの JSON の "フェーズ" の値(読めなければ null) +export function phaseChanged(planName: string, planPhase: string | null = null): Pick { + return { type: "phase.changed", payload: { phase: phaseNameOf(planName, planPhase), issues: phaseOf(planName).issues } }; } // claude の stream-json の result の modelUsage から、モデルごとに 1 件(#2170 の数え方: cache の読み書きを入力に足す) @@ -178,6 +205,40 @@ export function truncateGate(event: GateReachedEvent): GateReachedEvent { return build(lo); } +// 共通の列(common)に見込む大きさ。step.recorded の本体はこれを除いた分に収める +const COMMON_BUDGET = 1024; + +// UTF-8 で max バイト以下になるよう、文字の境目で切る +export function cutUtf8(text: string, max: number): { text: string; cut: boolean } { + if (Buffer.byteLength(text, "utf8") <= max) return { text, cut: false }; + let out = Buffer.from(text, "utf8").subarray(0, max).toString("utf8"); + // 途中で切れた文字は U+FFFD になるため落とす + while (out.endsWith("\uFFFD") || Buffer.byteLength(out, "utf8") > max) out = out.slice(0, -1); + return { text: out, cut: true }; +} + +// 事象 1 件の上限(256 KiB)を超える step.recorded を output.text → files → decisions の順に削る(I2)。 +// 削っても超えるなら工程の中身をすべて外す(足す前の形に戻る) +export function fitStep(payload: StepRecordedEvent["payload"]): StepRecordedEvent["payload"] { + const limit = MAX_EVENT_BYTES - COMMON_BUDGET; + const size = (p: StepRecordedEvent["payload"]) => bytes({ type: "step.recorded", payload: p }); + if (size(payload) <= limit) return payload; + let p: StepRecordedEvent["payload"] = { ...payload }; + if (p.output) { + const over = size(p) - limit; + const keep = Math.max(0, Buffer.byteLength(p.output.text, "utf8") - over); + p = { ...p, output: { ...p.output, text: cutUtf8(p.output.text, keep).text, truncated: true } }; + } + while (size(p) > limit && p.files && p.files.length > 0) { + const drop = Math.max(1, Math.ceil(p.files.length / 2)); + p = { ...p, files: p.files.slice(0, p.files.length - drop), files_omitted: (p.files_omitted ?? 0) + drop }; + } + while (size(p) > limit && p.decisions && p.decisions.length > 0) p = { ...p, decisions: p.decisions.slice(0, -1) }; + if (size(p) <= limit) return p; + const { step, exit_code, seconds, summary, pull_request } = payload; + return { step, exit_code, seconds, summary, ...(pull_request ? { pull_request } : {}) }; +} + // #2206 の代わりの判断の記録(1 行 1 件)から decision.delegated を作る。形が違う行は送らない。 // category は ask_ledger.py の decision の行に無いため、ref が指す判定の行(kind: verdict)の source から埋める export function delegatedFromRecord(record: Line, verdictSources: ReadonlyMap = new Map()): Pick | null { diff --git a/factory/web/e2e/paths.ts b/factory/web/e2e/paths.ts index f8336dd2f..e83bfa83c 100644 --- a/factory/web/e2e/paths.ts +++ b/factory/web/e2e/paths.ts @@ -2,7 +2,7 @@ export const PERSONAL = "00000000-0000-4000-8000-000000000001"; export const TEAM = "00000000-0000-4000-8000-000000000002"; -// ログインの後の画面(既存の 6 画面と見本の 2 画面) +// ログインの後の画面(既存の 6 画面と、スプリントの一覧・詳細・バックログ) export const SIGNED_IN_PATHS = [ "/account", "/teams/new", @@ -12,4 +12,6 @@ export const SIGNED_IN_PATHS = [ `/accounts/${PERSONAL}/repositories/101/sprints/new`, `/accounts/${PERSONAL}/sprints`, `/accounts/${PERSONAL}/sprints/sr-0002`, + `/accounts/${PERSONAL}/sprints/sr-0003`, + `/accounts/${PERSONAL}/repositories/101/backlog`, ]; diff --git a/factory/web/package.json b/factory/web/package.json index d56320335..17385678e 100644 --- a/factory/web/package.json +++ b/factory/web/package.json @@ -14,6 +14,7 @@ "e2e": "playwright test" }, "dependencies": { + "@ndf-factory/contracts": "*", "i18next": "^26.7.1", "oidc-client-ts": "^3.5.0", "react": "^19.3.1", diff --git a/factory/web/src/App.test.tsx b/factory/web/src/App.test.tsx index 0b568ba45..e6cc432b4 100644 --- a/factory/web/src/App.test.tsx +++ b/factory/web/src/App.test.tsx @@ -11,6 +11,7 @@ const config = { cognitoClientId: "web-client", webUrl: "https://factory.example.test", apiUrl: "https://api.example.test", + pollIntervalMs: 30_000, }; const me: Me = { @@ -90,10 +91,10 @@ describe("ログインの画面(受け入れ条件 1・2)", () => { expect(auth.getToken()).toBeNull(); }); - it("戻りの後に自分のアカウントの画面へ移り、プランが出る", async () => { + 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); - expect(await screen.findByText("プラン: Free")).toBeTruthy(); + expect(await screen.findByText("料金プラン: Free")).toBeTruthy(); expect(where()).toBe("/account"); }); }); @@ -106,7 +107,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => { expect(await screen.findByText("同時に動かせるスプリントの実行")).toBeTruthy(); expect(screen.getByText("1")).toBeTruthy(); expect(screen.getByText("掛けない")).toBeTruthy(); - expect(screen.getByText("実行の形態: local(手元のランナー)")).toBeTruthy(); + expect(screen.getByText("実行の形態: local(手元のマシン)")).toBeTruthy(); expect(screen.getByRole("link", { name: "開発" })).toBeTruthy(); expect((screen.getByRole("option", { name: "managed(サービスの Bedrock)" }) as HTMLOptionElement).disabled).toBe(true); }); diff --git a/factory/web/src/App.tsx b/factory/web/src/App.tsx index 8de6649c2..71b92d522 100644 --- a/factory/web/src/App.tsx +++ b/factory/web/src/App.tsx @@ -17,6 +17,7 @@ import { ModelCredentialPage } from "./pages/ModelCredentialPage.tsx"; import { NewSprintPage } from "./pages/NewSprintPage.tsx"; import { SprintsPage } from "./pages/SprintsPage.tsx"; import { SprintDetailPage } from "./pages/SprintDetailPage.tsx"; +import { BacklogPage } from "./pages/BacklogPage.tsx"; import { LoopsPage } from "./pages/LoopsPage.tsx"; import { LoopDetailPage } from "./pages/LoopDetailPage.tsx"; @@ -32,6 +33,7 @@ const ELEMENTS: Partial> = { newSprint: , sprints: , sprintDetail: , + backlog: , loops: , loopDetail: , }; diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts index 3c2f2f83c..bed32de43 100644 --- a/factory/web/src/api.ts +++ b/factory/web/src/api.ts @@ -1,4 +1,5 @@ import type { Auth } from "./auth.ts"; +import type { StepDetailBody } from "@ndf-factory/contracts/views"; // API の応答の形(factory/contracts/openapi/accounts.yaml) export type Plan = "free" | "pro" | "max" | "team"; @@ -59,7 +60,7 @@ export interface Repository { installation_id: number; registered_at: string; active_sprint_runs?: number; - // 同時の実行の数の宣言。無いか null ならプランの値だけで数える + // 同時の実行の数の宣言。無いか null なら料金プランの値だけで数える max_concurrent_runs?: number | null; } @@ -88,6 +89,10 @@ export interface IssueCandidates { note: string | null; } +// 実行の終わりの状態(API の TERMINAL_RUN_STATES と同じ)。終わった実行の中身はもう変わらない +const FINISHED_RUN_STATES: ReadonlySet = new Set(["done", "stopped", "failed", "cancelled"]); +export const isFinishedRun = (state: string) => FINISHED_RUN_STATES.has(state); + export interface SprintRunSummary { sprint_run_id: string; repository_id: number; @@ -97,6 +102,66 @@ export interface SprintRunSummary { created_at: string; } +// 一覧の行(#2172)。今のフェーズは最後の phase.changed の名前で、null は始まる前。5 つの名前に無い値もそのまま来る +export interface SprintRunRow extends SprintRunSummary { + repo: string; + current_phase: string | null; + current_step: string | null; + last_event_at: string | null; +} + +export interface PullRequestLink { + repo: string; + number: number; + url: string; +} + +export interface StepRow { + key: string; + at: string; + job_id: string | null; + step: string; + phase: string | null; + issues: number[]; + exit_code: number; + seconds: number; + cost_usd: number | null; + summary: string; + pull_request: PullRequestLink | null; + has_details: boolean; +} + +export type GateState = "not_reached" | "waiting" | "approved" | "rejected"; +export type DecisionState = "none" | "waiting" | "answered"; + +export interface SprintRunDetail extends SprintRunSummary { + repo: string; + ndf_version: string; + execution_mode: ExecutionMode; + job_count: number; + current_phase: string | null; + phases: { name: string; step_keys: string[]; required: { label: string; step: string; record: { key: string; exit_code: number; seconds: number } | null }[] }[]; + columns: { + issue: number | null; + step_keys: string[]; + pull_requests: (PullRequestLink & { state: "open" | "closed" | "merged" | "unknown"; checks: { name: string; conclusion: string | null }[] })[]; + }[]; + steps: StepRow[]; + gates: { gate1: { state: GateState; at: string | null }; gate2: { state: GateState; at: string | null }; decision: { state: DecisionState; at: string | null } }; + delegated_decisions: number; + usage: { input_tokens: number; output_tokens: number; cost_usd: number }; + omitted_steps: number; +} + +export type StepDetail = StepRow & StepDetailBody; + +export interface Backlog { + repository_id: number; + repo: string; + ranking: { milestone: string; rows: { rank: number; number: number; title: string }[] } | null; + refinements: { sprint_run_id: string; key: string; at: string; exit_code: number; summary: string; pull_request: PullRequestLink | null }[]; +} + // #2347 の開発ループ(factory/contracts/openapi/loops.yaml) export type LoopState = "running" | "between_laps" | "awaiting_approval" | "stopped" | "cancelled"; export type LoopWaitReason = "slots" | "legacy_version" | "user_start"; @@ -185,9 +250,13 @@ export interface Api { issueCandidates(id: string, repositoryId: number, exclude: number[]): Promise; startSprintRun(id: string, repositoryId: number, issues: number[]): Promise<{ sprint_run_id: string; state: string }>; // repositoryId を省くとアカウントの新しい順の 50 件(画面は 1 回だけ呼んでリポジトリごとに分ける) - sprintRuns(id: string, repositoryId?: number): Promise<{ sprint_runs: SprintRunSummary[] }>; - // 実行の ID の配列で受け、見つかった要素の配列を返す(1 件のときも要素 1 個の配列)。見つからない ID の要素は返さない - sprintRunDetails(id: string, sprintRunIds: string[]): Promise; + sprintRuns(id: string, repositoryId?: number): Promise<{ sprint_runs: SprintRunRow[] }>; + // 実行の ID の配列で受け、要素の配列を返す(1 件のときも要素 1 個の配列)。無い実行は API が他人の実行と同じ 403 を返し、そのまま投げる + sprintRunDetails(id: string, sprintRunIds: string[]): Promise; + // 工程の中身。工程の鍵(事象の idempotency_key)の配列で受ける + stepDetails(id: string, sprintRunId: string, stepKeys: string[]): Promise; + // バックログ(順位の表と棚卸しの記録)。リポジトリの ID の配列で受ける + backlogs(id: string, repositoryIds: number[]): Promise; cancelSprintRun(id: string, sprintRunId: string): Promise<{ state: string }>; // 同時の実行の数の宣言(持ち主だけ)。null は宣言を外す updateRepository(id: string, repositoryId: number, maxConcurrentRuns: number | null): Promise; @@ -245,12 +314,12 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. 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}`}`), - // 詳細の経路はまだ無いため、アカウントの新しい順の 50 件から選ぶ。#2172 が詳細の経路を足したらここだけを差し替える - sprintRunDetails: async (id, sprintRunIds) => { - const wanted = new Set(sprintRunIds); - const { sprint_runs } = await request<{ sprint_runs: SprintRunSummary[] }>("GET", `/accounts/${seg(id)}/sprint-runs`); - return sprint_runs.filter((r) => wanted.has(r.sprint_run_id)); - }, + // 経路は 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`), diff --git a/factory/web/src/auth.test.ts b/factory/web/src/auth.test.ts index 1c49fd111..fd48ccd23 100644 --- a/factory/web/src/auth.test.ts +++ b/factory/web/src/auth.test.ts @@ -6,6 +6,7 @@ const config = { cognitoClientId: "web-client", webUrl: "https://factory.example.test", apiUrl: "https://api.example.test", + pollIntervalMs: 30_000, }; function unsignedJwt(claims: Record) { diff --git a/factory/web/src/config.ts b/factory/web/src/config.ts index 49ff9dab0..f89825a6d 100644 --- a/factory/web/src/config.ts +++ b/factory/web/src/config.ts @@ -4,6 +4,8 @@ export interface FactoryConfig { cognitoClientId: string; webUrl: string; apiUrl: string; + // 一覧と詳細を読み直す間隔(ミリ秒。#2172 の決定 12) + pollIntervalMs: number; } declare const __FACTORY_CONFIG__: FactoryConfig; diff --git a/factory/web/src/context.tsx b/factory/web/src/context.tsx index ff9f3c720..fbe53641a 100644 --- a/factory/web/src/context.tsx +++ b/factory/web/src/context.tsx @@ -10,6 +10,8 @@ export interface Services { auth: Auth; api: Api; connection: ConnectionSource; + // 一覧と詳細を読み直す間隔(ミリ秒)。無ければ既定の 30 秒 + pollIntervalMs?: number; } export const ServicesContext = createContext(null); diff --git a/factory/web/src/dummy/api.ts b/factory/web/src/dummy/api.ts index 30a535820..1e00b781e 100644 --- a/factory/web/src/dummy/api.ts +++ b/factory/web/src/dummy/api.ts @@ -41,7 +41,31 @@ export function createDummyApi(scenario: Scenario = "normal"): Api { startSprintRun: () => read(() => ({ sprint_run_id: "sr-new", state: "starting" })), sprintRuns: (_id, repositoryId) => read(() => ({ sprint_runs: list(data.sprintRuns).filter((r) => repositoryId === undefined || r.repository_id === repositoryId) })), - sprintRunDetails: (_id, ids) => read(() => list(data.sprintRuns).filter((r) => ids.includes(r.sprint_run_id))), + // 無い実行は API と同じく 403 + sprintRunDetails: (_id, ids) => + read(() => + ids.map((id) => { + const found = list(data.sprintRunDetails).find((d) => d.sprint_run_id === id); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + return found; + }), + ), + stepDetails: (_id, sprintRunId, keys) => + read(() => + keys.map((key) => { + const found = data.stepDetailOf(sprintRunId, key); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + return found; + }), + ), + backlogs: (_id, repositoryIds) => + read(() => + repositoryIds.map((repositoryId) => { + const found = data.backlogs.find((b) => b.repository_id === repositoryId); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + return scenario === "empty" ? { ...found, ranking: null, refinements: [] } : found; + }), + ), cancelSprintRun: () => read(() => ({ state: "cancelled" })), updateRepository: (_id, repositoryId, max) => read(() => ({ ...(data.repositories.find((r) => r.repository_id === repositoryId) ?? data.repositories[0]!), max_concurrent_runs: max })), diff --git a/factory/web/src/dummy/data.ts b/factory/web/src/dummy/data.ts index 705193ae7..2c42cf860 100644 --- a/factory/web/src/dummy/data.ts +++ b/factory/web/src/dummy/data.ts @@ -1,4 +1,4 @@ -import type { AccountDetail, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunSummary } from "../api.ts"; +import type { AccountDetail, Backlog, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts"; // ダミーデータ。本番の成果物にこの印が出たら check:bundle が落とす。実在の人・秘密を書かない export const DUMMY_MARKER = "ndf-factory-dummy-data"; @@ -34,10 +34,168 @@ export const repositories: Repository[] = [ { repository_id: 102, full_name: "example-org/a-repository-with-a-very-long-name-for-narrow-screens", installation_id: 9001, registered_at: "2026-10-21T00:00:00Z", active_sprint_runs: 0 }, ]; -export const sprintRuns: SprintRunSummary[] = [ - { sprint_run_id: "sr-0003", repository_id: 101, issues: [12, 15], state: "awaiting_approval", failure_reason: null, created_at: "2026-11-01T23:30:00Z" }, - { sprint_run_id: "sr-0002", repository_id: 102, issues: [8], state: "failed", failure_reason: "timeout", created_at: "2026-10-31T10:00:00Z" }, - { sprint_run_id: "sr-0001", repository_id: 101, issues: [3, 4, 5], state: "done", failure_reason: null, created_at: "2026-10-30T01:15:00Z" }, +const REPO_A = "example-org/sample-service"; +const REPO_B = "example-org/a-repository-with-a-very-long-name-for-narrow-screens"; + +export const sprintRuns: SprintRunRow[] = [ + { + sprint_run_id: "sr-0003", + repository_id: 101, + issues: [12, 15], + state: "awaiting_approval", + failure_reason: null, + created_at: "2026-11-01T23:30:00Z", + repo: REPO_A, + current_phase: "実装", + current_step: "impl", + last_event_at: "2026-11-02T00:20:00Z", + }, + { + sprint_run_id: "sr-0002", + repository_id: 102, + issues: [8], + state: "failed", + failure_reason: "timeout", + created_at: "2026-10-31T10:00:00Z", + repo: REPO_B, + current_phase: null, + current_step: null, + last_event_at: null, + }, + { + sprint_run_id: "sr-0001", + repository_id: 101, + issues: [3, 4, 5], + state: "done", + failure_reason: null, + created_at: "2026-10-30T01:15:00Z", + repo: REPO_A, + current_phase: "仕上げ", + current_step: "refine", + last_event_at: "2026-10-30T05:00:00Z", + }, +]; + +// 工程の記録の行。key は job:連番、費用は結べたものだけ +function step(key: string, at: string, name: string, phase: string, issues: number[], patch: Partial = {}): StepRow { + return { + key, + at, + job_id: key.split(":")[0]!, + step: name, + phase, + issues, + exit_code: 0, + seconds: 120, + cost_usd: null, + summary: `${name} の要約(見本)`, + pull_request: null, + has_details: false, + ...patch, + }; +} + +const PHASES = ["設計", "実装", "検査", "取り込み", "仕上げ"]; + +function detailOf(row: SprintRunRow, steps: StepRow[], patch: Partial = {}): SprintRunDetail { + const keysOf = (pred: (s: StepRow) => boolean) => steps.filter(pred).map((s) => s.key); + const last = (name: string) => { + const s = steps.filter((x) => x.step === name).at(-1); + return s ? { key: s.key, exit_code: s.exit_code, seconds: s.seconds } : null; + }; + const pr = (issue: number | null) => + steps + .filter((s) => s.pull_request && (issue === null ? s.issues.length === 0 : s.issues.includes(issue))) + .map((s) => ({ ...s.pull_request!, state: "open" as const, checks: [{ name: "factory-check", conclusion: "success" }, { name: "lint", conclusion: null }] })); + return { + sprint_run_id: row.sprint_run_id, + repository_id: row.repository_id, + issues: row.issues, + state: row.state, + failure_reason: row.failure_reason, + created_at: row.created_at, + repo: row.repo, + ndf_version: "10.27.15", + execution_mode: "byok", + job_count: 1, + current_phase: row.current_phase, + phases: PHASES.map((name) => ({ + name, + step_keys: keysOf((s) => s.phase === name), + required: name === "仕上げ" ? [{ label: "振り返り", step: "retro", record: last("retro") }, { label: "棚卸し", step: "refine", record: last("refine") }] : [], + })), + columns: [...row.issues.map((issue) => ({ issue, step_keys: keysOf((s) => s.issues.includes(issue)), pull_requests: pr(issue) })), { issue: null, step_keys: keysOf((s) => s.issues.length === 0), pull_requests: pr(null) }], + steps, + gates: { gate1: { state: "not_reached", at: null }, gate2: { state: "not_reached", at: null }, decision: { state: "none", at: null } }, + delegated_decisions: 0, + usage: { input_tokens: 1_250_000, output_tokens: 48_000, cost_usd: 3.75 }, + omitted_steps: 0, + ...patch, + }; +} + +const sr3Steps = [ + step("job-a:1", "2026-11-01T23:40:00Z", "design", "設計", [12], { cost_usd: 1.25, has_details: true, pull_request: { repo: REPO_A, number: 41, url: `https://github.com/${REPO_A}/pull/41` } }), + step("job-a:2", "2026-11-01T23:45:00Z", "design", "設計", [15], { cost_usd: 1.1 }), + step("job-a:3", "2026-11-01T23:50:00Z", "sprint-branch", "実装", []), + step("job-a:4", "2026-11-02T00:20:00Z", "impl", "実装", [12], { cost_usd: 1.4, seconds: 1800, has_details: true }), +]; + +const sr1Steps = [ + step("job-c:1", "2026-10-30T02:00:00Z", "impl", "実装", [3, 4, 5], { cost_usd: 2 }), + step("job-c:2", "2026-10-30T03:00:00Z", "check", "検査", []), + step("job-c:3", "2026-10-30T04:00:00Z", "release", "取り込み", []), + step("job-c:4", "2026-10-30T04:30:00Z", "retro", "仕上げ", [], { seconds: 300 }), + step("job-c:5", "2026-10-30T05:00:00Z", "refine", "仕上げ", [], { seconds: 240, summary: "順位を見直した(見本)" }), +]; + +export const sprintRunDetails: SprintRunDetail[] = [ + detailOf(sprintRuns[0]!, sr3Steps, { + gates: { gate1: { state: "waiting", at: "2026-11-02T00:20:00Z" }, gate2: { state: "not_reached", at: null }, decision: { state: "none", at: null } }, + delegated_decisions: 2, + }), + detailOf(sprintRuns[1]!, []), + detailOf(sprintRuns[2]!, sr1Steps, { + gates: { gate1: { state: "approved", at: "2026-10-30T01:50:00Z" }, gate2: { state: "approved", at: "2026-10-30T04:10:00Z" }, decision: { state: "none", at: null } }, + }), +]; + +const sha = (c: string) => c.repeat(40); + +// 工程の中身。中身を持たない工程は欄がどれも null +export function stepDetailOf(sprintRunId: string, key: string): StepDetail | null { + const row = sprintRunDetails.find((d) => d.sprint_run_id === sprintRunId)?.steps.find((s) => s.key === key); + if (!row) return null; + if (!row.has_details) return { ...row, files: null, files_omitted: null, commit_range: null, decisions: null, output: null, diff_url: null }; + const range = { from: sha("a"), to: sha("b") }; + return { + ...row, + files: [ + { path: "src/service/handler.ts", added: 42, deleted: 7, url: `https://github.com/${REPO_A}/blob/${range.to}/src/service/handler.ts` }, + { path: "docs/design.md", added: 120, deleted: 0, url: `https://github.com/${REPO_A}/blob/${range.to}/docs/design.md` }, + ], + files_omitted: null, + commit_range: range, + decisions: [{ question: "見本の問い: 一覧の並びはどちらにするか", answer: "新しい順にする", by: "ai" }], + output: { kind: "report", text: "## 作業の報告(見本)\n- 作業: 実装\n- 結果: 完了\n", truncated: false }, + diff_url: row.pull_request ? `${row.pull_request.url}/files` : `https://github.com/${REPO_A}/compare/${range.from}...${range.to}`, + }; +} + +export const backlogs: Backlog[] = [ + { + repository_id: 101, + repo: REPO_A, + ranking: { + milestone: "01 見本", + rows: [ + { rank: 1, number: 21, title: "見本の課題 1" }, + { rank: 2, number: 22, title: "見本の課題 2" }, + ], + }, + refinements: [{ sprint_run_id: "sr-0001", key: "job-c:5", at: "2026-10-30T05:00:00Z", exit_code: 0, summary: "順位を見直した(見本)", pull_request: null }], + }, + { repository_id: 102, repo: REPO_B, ranking: null, refinements: [] }, ]; export const issueCandidates: IssueCandidates = { diff --git a/factory/web/src/i18n/ja.json b/factory/web/src/i18n/ja.json index 44a92001a..b07cc7b59 100644 --- a/factory/web/src/i18n/ja.json +++ b/factory/web/src/i18n/ja.json @@ -27,7 +27,7 @@ "backlog": "バックログ", "approvals": "承認", "billing": "請求と使用量", - "runners": "手元のランナー", + "runners": "手元のマシン", "loops": "ループ", "loopDetail": "ループの詳細" }, @@ -61,7 +61,7 @@ "team": "Team" }, "mode": { - "local": "local(手元のランナー)", + "local": "local(手元のマシン)", "byok": "byok(キーの持ち込み)", "managed": "managed(サービスの Bedrock)" }, @@ -82,7 +82,7 @@ "idpGoogle": "Google", "idpGithub": "GitHub", "personal": "個人のアカウント", - "plan": "プラン: {{plan}}", + "plan": "料金プラン: {{plan}}", "teams": "チーム", "teamRole": "({{role}})", "owner": "持ち主", @@ -115,18 +115,19 @@ "run": "{{issues}}: {{state}}", "runWithReason": "{{issues}}: {{state}}({{reason}})", "loops": "ループ", - "maxConcurrent": "同時の実行の数(空ならプランの値)", + "maxConcurrent": "同時の実行の数(空なら料金プランの値)", "maxConcurrentRange": "1 から 100 の整数を入れてください", - "saveMaxConcurrent": "宣言する" + "saveMaxConcurrent": "宣言する", + "backlog": "バックログ" }, "sprintState": { "starting": "起動中", - "running": "動いている", + "running": "実行中", "awaiting_approval": "承認待ち", "done": "終わった", "stopped": "止まった", "failed": "失敗", - "cancelled": "取り消した", + "cancelled": "取り消し", "awaiting_reset": "利用上限の待ち" }, "modelCredential": { @@ -166,13 +167,104 @@ "createdAt": "始めた日時", "empty": "スプリントの実行はまだありません", "noMatch": "絞り込みに合う実行はありません", - "repositoryId": "リポジトリ {{id}}" + "repositoryId": "リポジトリ {{id}}", + "phase": "フェーズ", + "currentStep": "今の工程: {{step}}", + "lastEventAt": "最後の動き", + "noEvent": "—", + "summary": "件数", + "activeCount": "動いている", + "waitingCount": "対応待ち", + "tabs": "状態で絞る", + "tabAll": "すべて", + "tabActive": "動いている", + "tabWaiting": "対応待ち", + "tabFinished": "終わった" }, "sprintDetail": { - "notFound": "このスプリントの実行は見つかりません", "failureReason": "失敗の理由", "none": "無し", - "back": "スプリントの一覧へ戻る" + "back": "スプリントの一覧へ戻る", + "openGitHub": "GitHub で開く", + "overview": "概要", + "ndfVersion": "NDF の版", + "executionMode": "実行の形態", + "jobCount": "実行ジョブの数", + "phases": "フェーズ", + "phaseSteps": "{{count}} 件の工程", + "required": "必須の工程", + "notYet": "まだ", + "recordResult": "終了コード {{code}}・{{seconds}} 秒", + "steps": "工程の記録", + "stepsEmpty": "工程の記録はまだありません", + "columns": "課題で絞る", + "columnAll": "すべて", + "columnSprint": "スプリント全体", + "columnIssue": "#{{issue}}", + "omitted": "古い工程 {{count}} 件は省きました", + "stepLine": "{{step}}({{phase}})", + "stepMeta": "終了コード {{code}}・{{seconds}} 秒・{{at}}", + "gates": "承認ゲート", + "gate1": "ゲート 1", + "gate2": "ゲート 2", + "decision": "判断", + "gateState": { + "not_reached": "まだ", + "waiting": "承認待ち", + "approved": "承認された", + "rejected": "差し戻された" + }, + "decisionState": { + "none": "無し", + "waiting": "判断を待っている", + "answered": "判断に答えた" + }, + "pullRequests": "Pull Request と CI", + "pullRequestsEmpty": "Pull Request はまだありません", + "prLabel": "#{{number}}", + "prState": { + "open": "open", + "closed": "closed", + "merged": "merged", + "unknown": "不明" + }, + "checkRunning": "実行中", + "checkLine": "{{name}}: {{conclusion}}", + "delegated": "AI が代わりに判断した問い合わせ", + "usage": "使用量", + "inputTokens": "入力の token", + "outputTokens": "出力の token", + "cost": "費用", + "panel": "工程の詳細", + "backToOverview": "概要に戻る", + "stepName": "ステップ", + "exitCode": "終了コード", + "seconds": "秒", + "stepCost": "費用", + "summaryLabel": "要約", + "noCost": "—", + "files": "触ったファイル", + "filesOmitted": "ほか {{count}} 件", + "fileChange": "+{{added}} −{{deleted}}", + "binary": "バイナリ", + "decisions": "この工程で決めたこと", + "decisionLine": "{{question}} → {{answer}}", + "decidedBy": { + "ai": "AI", + "user": "利用者" + }, + "noDecisions": "決めたことはありません", + "noFiles": "触ったファイルはありません", + "diff": "差分を GitHub で見る", + "openReport": "作業の報告を開く", + "openLog": "ログを開く", + "closeOutput": "閉じる", + "truncated": "長いため途中で切りました", + "noRecord": "記録なし", + "requiredStep": { + "retro": "振り返り", + "refine": "棚卸し" + } }, "loopState": { "running": "動いている", @@ -265,5 +357,38 @@ "cancel": "ループを止める", "nextLap": "次の周回を始める", "minutes": "{{value}} 分" + }, + "phase": { + "design": "設計", + "impl": "実装", + "check": "検査", + "integrate": "取り込み", + "finish": "仕上げ", + "notStarted": "始まる前", + "other": "その他({{name}})", + "bars": "フェーズの進み", + "passed": "{{name}}(通った)", + "current": "{{name}}(今)", + "ahead": "{{name}}(まだ)" + }, + "poll": { + "stale": "読み直せませんでした({{time}})" + }, + "backlog": { + "lastRefinement": "最後の棚卸し", + "milestone": "順位の表のマイルストーン", + "none": "—", + "ranking": "着手の順位", + "rankingEmpty": "順位の表がありません", + "rank": "順位", + "issue": "課題", + "refinements": "棚卸しの記録", + "refinementsEmpty": "棚卸しの記録はまだありません", + "at": "日時", + "summary": "要約", + "pullRequest": "Pull Request", + "exitCode": "終了コード", + "back": "リポジトリへ戻る", + "issueLine": "#{{number}} {{title}}" } } diff --git a/factory/web/src/main.tsx b/factory/web/src/main.tsx index bdc250695..16f3b25c3 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() }; + return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs }; } const style = document.createElement("style"); diff --git a/factory/web/src/pages/BacklogPage.tsx b/factory/web/src/pages/BacklogPage.tsx new file mode 100644 index 000000000..9be25bb09 --- /dev/null +++ b/factory/web/src/pages/BacklogPage.tsx @@ -0,0 +1,112 @@ +import { useCallback, type ReactNode } from "react"; +import { Link, Navigate, useParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Backlog } from "../api.ts"; +import { useServices } from "../context.tsx"; +import { useFormat } from "../i18n/index.ts"; +import { usePolled } from "../poll.ts"; +import { Button } from "../ui/Button.tsx"; +import { DataList } from "../ui/DataList.tsx"; +import { DetailList } from "../ui/DetailList.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"; + +type Refinement = Backlog["refinements"][number]; +type RankRow = NonNullable["rows"][number]; + +// バックログの画面(#2172)。順位の表と棚卸しの記録をスプリントの外で読む。読むだけで、課題・マイルストーン・ボードへ書く操作を持たない +export function BacklogPage() { + const { t } = useTranslation(); + const format = useFormat(); + const { accountId = "", repositoryId = "" } = useParams(); + const { auth, api } = useServices(); + const hasToken = auth.getToken() !== null; + const read = useCallback(async () => (await api.backlogs(accountId, [Number(repositoryId)]))[0] ?? null, [api, accountId, repositoryId]); + // 1 回の読み出しが GitHub の全ページと実行ごとの事象を読むため、間隔では読み直さない(開いたときと「もう一度読む」だけ) + const { data: backlog, error, reload } = usePolled(read, hasToken, { poll: false }); + + if (!hasToken) return ; + const header = (title: string, actions?: ReactNode) => ( + <> + + {t("backlog.back")} + + + + ); + if (error) { + return ( + <> + {header(t("screens.backlog"))} + + + ); + } + if (backlog === null) { + return ( + <> + {header(t("screens.backlog"))} + + + ); + } + const last = backlog.refinements[0]; + return ( + <> + {header(backlog.repo, )} + +
+ {backlog.ranking === null ? ( + + ) : ( + + label={t("backlog.ranking")} + rows={backlog.ranking.rows} + rowKey={(r) => String(r.number)} + columns={[ + { key: "rank", header: t("backlog.rank"), render: (r) => format.number(r.rank) }, + { key: "issue", header: t("backlog.issue"), render: (r) => t("backlog.issueLine", { number: r.number, title: r.title }) }, + ]} + /> + )} +
+
+ {backlog.refinements.length === 0 ? ( + + ) : ( + + label={t("backlog.refinements")} + rows={backlog.refinements} + rowKey={(r) => `${r.sprint_run_id}-${r.key}`} + rowHref={(r) => `/accounts/${accountId}/sprints/${encodeURIComponent(r.sprint_run_id)}`} + columns={[ + { key: "at", header: t("backlog.at"), render: (r) => format.dateTime(r.at) }, + { key: "summary", header: t("backlog.summary"), render: (r) => r.summary }, + { + key: "pr", + header: t("backlog.pullRequest"), + render: (r) => + r.pull_request ? ( + + {t("sprintDetail.prLabel", { number: r.pull_request.number })} + + ) : ( + t("backlog.none") + ), + }, + { key: "exit", header: t("backlog.exitCode"), render: (r) => String(r.exit_code) }, + ]} + /> + )} +
+ + ); +} diff --git a/factory/web/src/pages/RepositoriesPage.tsx b/factory/web/src/pages/RepositoriesPage.tsx index e4d1622c6..381d12bd1 100644 --- a/factory/web/src/pages/RepositoriesPage.tsx +++ b/factory/web/src/pages/RepositoriesPage.tsx @@ -112,6 +112,7 @@ export function RepositoriesPage() { {r.full_name}{" "} {t("screens.newSprint")}{" "} {t("repositories.loops")}{" "} + {t("repositories.backlog")}{" "} {owner && ( + ); + const noRecord = t("sprintDetail.noRecord"); + return ( +
+ {back} + {error ? ( + {error} + ) : !step ? ( + + ) : ( + <> + +

{t("sprintDetail.files")}

+ +

{t("sprintDetail.decisions")}

+ +
+ {step.diff_url && ( + + {t("sprintDetail.diff")} + + )} + {step.output ? ( + + ) : ( + {noRecord} + )} +
+ {step.output && showOutput && ( + <> + {step.output.truncated &&

{t("sprintDetail.truncated")}

} +
{step.output.text}
+ + )} + + )} +
+ ); +} + +// 工程の記録(新しいものが上)。課題の欄ごとに絞り、押すと右が詳細パネルになる +function StepLog({ detail, selected, onSelect }: { detail: SprintRunDetail; selected: string | null; onSelect: (key: string) => void }) { + const { t } = useTranslation(); + const format = useFormat(); + const phaseLabel = usePhaseLabel(); + const [column, setColumn] = useState("all"); + const keys = column === "all" ? null : new Set(detail.columns.find((c) => c.issue === column)?.step_keys ?? []); + const rows: StepRow[] = [...detail.steps].reverse().filter((s) => keys === null || keys.has(s.key)); + const columnLabel = (issue: number | null) => (issue === null ? t("sprintDetail.columnSprint") : t("sprintDetail.columnIssue", { issue })); + return ( +
+
+ + {detail.columns.map((c) => ( + + ))} +
+ {detail.omitted_steps > 0 &&

{t("sprintDetail.omitted", { count: detail.omitted_steps })}

} + {rows.length === 0 ? ( + + ) : ( +
    + {rows.map((s) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +// フェーズの欄。仕上げには振り返りと棚卸しの行を常に置く +function PhasesView({ detail }: { detail: SprintRunDetail }) { + const { t } = useTranslation(); + const label = usePhaseLabel(); + return ( +
+ +
    + {detail.phases.map((p) => ( +
  • + {label(p.name)} {t("sprintDetail.phaseSteps", { count: p.step_keys.length })} + {p.required.length > 0 && ( + ({ + term: r.step in REQUIRED_KEYS ? t(REQUIRED_KEYS[r.step as keyof typeof REQUIRED_KEYS]) : r.label, + value: r.record === null ? t("sprintDetail.notYet") : t("sprintDetail.recordResult", { code: r.record.exit_code, seconds: r.record.seconds }), + }))} + /> + )} +
  • + ))} +
+
+ ); +} + +// スプリントの詳細(#2172)。開いている間は読み直し(終わった実行は除く)、工程を押すと右が詳細パネルに切り替わる export function SprintDetailPage() { const { t } = useTranslation(); const format = useFormat(); const sprintState = useSprintState(); const { accountId = "", sprintRunId = "" } = useParams(); const { auth, api } = useServices(); - const onFailure = useFailureHandler(); - const [loaded, setLoaded] = useState(null); - const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); const hasToken = auth.getToken() !== null; - const load = useCallback(async () => { - setError(null); - setLoaded(null); - try { - const [runs, names] = await Promise.all([api.sprintRunDetails(accountId, [sprintRunId]), repositoryNames(() => api.repositories(accountId))]); - const run = runs[0] ?? null; - setLoaded({ run, repository: run ? (names.get(run.repository_id) ?? null) : null }); - } catch (err) { - setError(onFailure(err)); - } - }, [api, accountId, sprintRunId, onFailure]); - - useEffect(() => { - if (hasToken) void load(); - }, [hasToken, load]); + const read = useCallback(async () => (await api.sprintRunDetails(accountId, [sprintRunId]))[0] ?? null, [api, accountId, sprintRunId]); + // 終わった実行は変わらないため、間隔では読み直さない(開いたときと手動の読み直しだけ。1 回の読み出しが事象の全件を読む) + const { data: detail, error, staleSince, reload } = usePolled(read, hasToken, { poll: (d) => d === null || !isFinishedRun(d.state) }); if (!hasToken) return ; const back = ( @@ -59,11 +307,11 @@ export function SprintDetailPage() { return ( <> {header(t("screens.sprintDetail"))} - void load()} /> + ); } - if (!loaded) { + if (detail === null) { return ( <> {header(t("screens.sprintDetail"))} @@ -71,29 +319,41 @@ export function SprintDetailPage() { ); } - const { run, repository } = loaded; - if (!run) { - return ( - <> - {header(t("screens.sprintDetail"))} - - - ); - } - const state = sprintState(run.state); + const state = sprintState(detail.state); return ( <> - {header(issueNumbers(run))} - }, - { term: t("sprintDetail.failureReason"), value: run.failure_reason ?? t("sprintDetail.none") }, - { term: t("sprints.createdAt"), value: format.dateTime(run.created_at) }, - ]} - /> + {header(issueNumbers(detail))} + +
+ {detail.repo}{" "} + + {t("sprintDetail.openGitHub")} + +
+
+ +
+ +
+
+ +
+
+ {selected === null ? ( + + ) : ( + setSelected(null)} /> + )} +
+
); } diff --git a/factory/web/src/pages/SprintsPage.tsx b/factory/web/src/pages/SprintsPage.tsx index 28a630a9e..a4f1129a1 100644 --- a/factory/web/src/pages/SprintsPage.tsx +++ b/factory/web/src/pages/SprintsPage.tsx @@ -1,10 +1,13 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Navigate, useParams } from "react-router"; import { useTranslation } from "react-i18next"; -import type { Repository, SprintRunSummary } from "../api.ts"; -import { useFailureHandler, useServices } from "../context.tsx"; +import type { Repository, SprintRunRow } from "../api.ts"; +import { useServices } from "../context.tsx"; import { useFormat } from "../i18n/index.ts"; +import { usePolled } from "../poll.ts"; +import { Button } from "../ui/Button.tsx"; import { DataList } from "../ui/DataList.tsx"; +import { DetailList } from "../ui/DetailList.tsx"; import { EmptyState } from "../ui/EmptyState.tsx"; import { ErrorState } from "../ui/ErrorState.tsx"; import { LoadingState } from "../ui/LoadingState.tsx"; @@ -12,8 +15,7 @@ import { PageHeader } from "../ui/PageHeader.tsx"; import { SearchField } from "../ui/SearchField.tsx"; import { StatusBadge } from "../ui/StatusBadge.tsx"; import { issueNumbers, useSprintState } from "./parts.tsx"; - -type Loaded = { runs: SprintRunSummary[]; repositories: Map }; +import { PhaseBars, StaleNotice } from "./sprintParts.tsx"; // リポジトリの ID から名前を引く表。名前が読めなくても一覧は出す export async function repositoryNames(list: () => Promise<{ repositories: Repository[] }>): Promise> { @@ -24,34 +26,34 @@ export async function repositoryNames(list: () => Promise<{ repositories: Reposi } } -// スプリントの一覧(見本の画面)。中身は #2172 が置き換える +type Tab = "all" | "active" | "waiting" | "finished"; +const TABS: readonly Tab[] = ["all", "active", "waiting", "finished"]; +const TAB_KEYS = { all: "sprints.tabAll", active: "sprints.tabActive", waiting: "sprints.tabWaiting", finished: "sprints.tabFinished" } as const; + +// 動いている(starting・running・利用上限の待ち)・対応待ち(awaiting_approval)・終わった(それ以外) +const ACTIVE = new Set(["starting", "running", "awaiting_reset"]); +function tabOf(state: string): Exclude { + if (ACTIVE.has(state)) return "active"; + if (state === "awaiting_approval") return "waiting"; + return "finished"; +} + +// スプリントの一覧(#2172)。並列に流れる実行の今のフェーズと状態を出し、開いている間は読み直す export function SprintsPage() { const { t } = useTranslation(); const format = useFormat(); const sprintState = useSprintState(); const { accountId = "" } = useParams(); const { auth, api } = useServices(); - const onFailure = useFailureHandler(); - const [loaded, setLoaded] = useState(null); - const [error, setError] = useState(null); const [query, setQuery] = useState(""); + const [tab, setTab] = useState("all"); const hasToken = auth.getToken() !== null; - const load = useCallback(async () => { - setError(null); - setLoaded(null); - try { - const [runs, repositories] = await Promise.all([api.sprintRuns(accountId), repositoryNames(() => api.repositories(accountId))]); - const sorted = [...runs.sprint_runs].sort((a, b) => b.created_at.localeCompare(a.created_at)); - setLoaded({ runs: sorted, repositories }); - } catch (err) { - setError(onFailure(err)); - } - }, [api, accountId, onFailure]); - - useEffect(() => { - if (hasToken) void load(); - }, [hasToken, load]); + const read = useCallback(async () => { + const runs = await api.sprintRuns(accountId); + return [...runs.sprint_runs].sort((a, b) => b.created_at.localeCompare(a.created_at)); + }, [api, accountId]); + const { data: runs, error, staleSince, reload } = usePolled(read, hasToken); if (!hasToken) return ; const header = ; @@ -59,11 +61,11 @@ export function SprintsPage() { return ( <> {header} - void load()} /> + ); } - if (!loaded) { + if (!runs) { return ( <> {header} @@ -72,25 +74,52 @@ export function SprintsPage() { ); } const needle = query.trim().replace(/^#/, ""); - const rows = needle === "" ? loaded.runs : loaded.runs.filter((r) => r.issues.some((n) => String(n).includes(needle))); + const byTab = tab === "all" ? runs : runs.filter((r) => tabOf(r.state) === tab); + const rows = needle === "" ? byTab : byTab.filter((r) => r.issues.some((n) => String(n).includes(needle))); return ( <> {header} - {loaded.runs.length === 0 ? ( + + {runs.length === 0 ? ( ) : ( <> + r.state === "starting" || r.state === "running").length) }, + { term: t("sprints.waitingCount"), value: format.number(runs.filter((r) => r.state === "awaiting_approval").length) }, + ]} + /> +
+ {TABS.map((x) => ( + + ))} +
{rows.length === 0 ? ( ) : ( - label={t("sprints.list")} rows={rows} rowKey={(r) => r.sprint_run_id} rowHref={(r) => `/accounts/${accountId}/sprints/${encodeURIComponent(r.sprint_run_id)}`} columns={[ { key: "issues", header: t("sprints.issues"), render: (r) => issueNumbers(r) }, + { key: "repository", header: t("sprints.repository"), render: (r) => r.repo || t("sprints.repositoryId", { id: r.repository_id }) }, + { + key: "phase", + header: t("sprints.phase"), + render: (r) => ( + <> + + {r.current_step !== null && {t("sprints.currentStep", { step: r.current_step })}} + + ), + }, { key: "state", header: t("sprints.state"), @@ -99,12 +128,8 @@ export function SprintsPage() { return ; }, }, - { - key: "repository", - header: t("sprints.repository"), - render: (r) => loaded.repositories.get(r.repository_id) ?? t("sprints.repositoryId", { id: r.repository_id }), - }, { key: "createdAt", header: t("sprints.createdAt"), render: (r) => format.dateTime(r.created_at) }, + { key: "lastEventAt", header: t("sprints.lastEventAt"), render: (r) => (r.last_event_at === null ? t("sprints.noEvent") : format.dateTime(r.last_event_at)) }, ]} /> )} diff --git a/factory/web/src/pages/loops.test.tsx b/factory/web/src/pages/loops.test.tsx index bc0bf1b71..777305142 100644 --- a/factory/web/src/pages/loops.test.tsx +++ b/factory/web/src/pages/loops.test.tsx @@ -144,7 +144,7 @@ describe("リポジトリの同時の実行の数(受け入れ条件 8)", () const update = vi.spyOn(api, "updateRepository"); const cards = await screen.findByRole("list", { name: "登録したリポジトリ" }); const first = within(cards).getAllByRole("listitem")[0]!; - const field = within(first).getByRole("textbox", { name: "同時の実行の数(空ならプランの値)" }); + const field = within(first).getByRole("textbox", { name: "同時の実行の数(空なら料金プランの値)" }); await userEvent.type(field, "101"); await userEvent.click(within(first).getByRole("button", { name: "宣言する" })); expect(within(first).getByText("1 から 100 の整数を入れてください")).toBeTruthy(); diff --git a/factory/web/src/pages/sample.test.tsx b/factory/web/src/pages/sample.test.tsx index d492dcfc2..ed556d4f0 100644 --- a/factory/web/src/pages/sample.test.tsx +++ b/factory/web/src/pages/sample.test.tsx @@ -65,9 +65,9 @@ describe("見本の画面(受け入れ条件 7・8)", () => { expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("スプリントの一覧"); }); - it("見つからない実行の詳細は空の状態を出す", async () => { + it("見つからない実行の詳細は、他人の実行と同じく権限が無い旨を出す(有無を明かさない)", async () => { show(`${sprints}/sr-none`); - expect(await screen.findByText("このスプリントの実行は見つかりません")).toBeTruthy(); + expect(await screen.findByText("このアカウントを見る権限がありません")).toBeTruthy(); }); }); @@ -112,7 +112,7 @@ describe("言語を足す(受け入れ条件 13・I6)", () => { expect(within(table).getByRole("columnheader", { name: "Issues" })).toBeTruthy(); // 訳の無いキーは日本語で出る expect(within(table).getByRole("columnheader", { name: "状態" })).toBeTruthy(); - expect(within(table).getByText(/11\/1\/2026/)).toBeTruthy(); + expect(within(table).getAllByText(/11\/1\/2026/).length).toBeGreaterThan(0); }); it("ブラウザの言語に翻訳が無ければ日本語で出る", async () => { diff --git a/factory/web/src/pages/sprintParts.tsx b/factory/web/src/pages/sprintParts.tsx new file mode 100644 index 000000000..69ac8af4d --- /dev/null +++ b/factory/web/src/pages/sprintParts.tsx @@ -0,0 +1,46 @@ +// スプリントの一覧と詳細が共有する部品(#2172)。フェーズの名前は契約の列挙(@ndf-factory/contracts/phases)を正にする +import { PHASE_NAMES, type PhaseName } from "@ndf-factory/contracts/phases"; +import { useTranslation } from "react-i18next"; +import { useFormat } from "../i18n/index.ts"; +import { Alert } from "../ui/Alert.tsx"; + +// 列挙の順と同じ並びの翻訳のキー +const PHASE_KEYS = ["design", "impl", "check", "integrate", "finish"] as const satisfies { length: (typeof PHASE_NAMES)["length"] }; + +// フェーズの名前の表示。null は始まる前、5 つに無い名前は「その他(<名前>)」 +export function usePhaseLabel(): (name: string | null) => string { + const { t } = useTranslation(); + return (name) => { + if (name === null) return t("phase.notStarted"); + const i = (PHASE_NAMES as readonly string[]).indexOf(name); + return i < 0 ? t("phase.other", { name }) : t(`phase.${PHASE_KEYS[i]!}`); + }; +} + +export function phaseIndex(name: string | null): number { + return name === null ? -1 : (PHASE_NAMES as readonly string[]).indexOf(name); +} + +// 5 つの棒。今より前は濃く、今は薄く。始まる前と 5 つに無い名前は文字だけで出す +export function PhaseBars({ current }: { current: string | null }) { + const { t } = useTranslation(); + const label = usePhaseLabel(); + const at = phaseIndex(current); + if (at < 0) return {label(current)}; + return ( + + {PHASE_NAMES.map((name: PhaseName, i) => ( + + ))} + {label(current)} + + ); +} + +// 読み直しが失敗したときの断り。前の表示は残す +export function StaleNotice({ since }: { since: string | null }) { + const { t } = useTranslation(); + const format = useFormat(); + if (since === null) return null; + return {t("poll.stale", { time: format.dateTime(since) })}; +} diff --git a/factory/web/src/pages/sprints.test.tsx b/factory/web/src/pages/sprints.test.tsx new file mode 100644 index 000000000..554515547 --- /dev/null +++ b/factory/web/src/pages/sprints.test.tsx @@ -0,0 +1,306 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, useLocation } from "react-router"; +import { App } from "../App.tsx"; +import { ApiFailure, createApi, type Api, type SprintRunRow, type StepDetail } from "../api.ts"; +import type { Auth } from "../auth.ts"; +import { fixedConnection } from "../connection.ts"; +import { createDummyApi, createDummyAuth } from "../dummy/index.ts"; +import { PERSONAL_ID, sprintRuns } from "../dummy/data.ts"; +import type { LocaleSettings } from "../i18n/settings.ts"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const tokyo: LocaleSettings = { language: "ja", locale: "ja-JP", timeZone: "Asia/Tokyo" }; +const online = fixedConnection({ online: true, lastReceivedAt: null, pendingOperations: 0 }); +const sprints = `/accounts/${PERSONAL_ID}/sprints`; + +function Where() { + const l = useLocation(); + return {l.pathname}; +} + +function show(path: string, api: Api = createDummyApi(), auth: Auth = createDummyAuth(), pollIntervalMs?: number) { + return render( + + + + , + ); +} + +describe("スプリントの一覧(受け入れ条件 1〜3)", () => { + it("各行に今のフェーズと今の工程を出し、phase.changed の無い実行は「始まる前」、5 つに無い名前は「その他(<名前>)」", async () => { + const api = createDummyApi(); + const rows: SprintRunRow[] = [ + { ...sprintRuns[0]!, current_phase: "設計" }, + { ...sprintRuns[1]!, sprint_run_id: "sr-x", current_phase: "検査", created_at: "2026-10-31T11:00:00Z" }, + { ...sprintRuns[2]!, current_phase: "仕上げ" }, + { ...sprintRuns[1]!, current_phase: null }, + { ...sprintRuns[1]!, sprint_run_id: "sr-y", current_phase: "準備", created_at: "2026-10-29T00:00:00Z" }, + ]; + api.sprintRuns = async () => ({ sprint_runs: rows }); + show(sprints, api); + const table = await screen.findByRole("table", { name: "スプリントの実行" }); + const phases = within(table) + .getAllByRole("row") + .slice(1) + .map((r) => r.querySelector("[data-label='フェーズ'] .phase-text")?.textContent); + expect(phases).toEqual(["設計", "検査", "始まる前", "仕上げ", "その他(準備)"]); + expect(within(table).getAllByText("今の工程: impl")).toHaveLength(1); + expect(within(table).getByRole("img", { name: "検査(今)" })).toBeTruthy(); + }); + + it("件数の札と、状態のタブ(動いている・対応待ち・終わった)で画面の中で絞る", async () => { + show(sprints); + const table = await screen.findByRole("table"); + const summary = within(screen.getByLabelText("件数")).getAllByRole("definition"); + expect(summary.map((d) => d.textContent)).toEqual(["0", "1"]); + await userEvent.click(screen.getByRole("button", { name: "対応待ち" })); + expect(within(screen.getByRole("table")).getAllByRole("link").map((a) => a.textContent)).toEqual(["#12 #15"]); + await userEvent.click(screen.getByRole("button", { name: "終わった" })); + expect(within(screen.getByRole("table")).getAllByRole("link").map((a) => a.textContent)).toEqual(["#8", "#3 #4 #5"]); + await userEvent.click(screen.getByRole("button", { name: "動いている" })); + expect(screen.getByText("絞り込みに合う実行はありません")).toBeTruthy(); + expect(table).toBeTruthy(); + }); +}); + +const detail = `${sprints}/sr-0003`; + +describe("スプリントの詳細(受け入れ条件 5〜9)", () => { + it("承認の欄はゲート 1・ゲート 2・判断の 3 つで、ゲート 1 が承認待ち。仕上げに振り返りと棚卸しが「まだ」で並ぶ", async () => { + show(detail); + const gates = await screen.findByRole("region", { name: "承認ゲート" }); + expect(within(gates).getAllByRole("term").map((x) => x.textContent)).toEqual(["ゲート 1", "ゲート 2", "判断"]); + expect(within(gates).getAllByRole("definition").map((x) => x.textContent)).toEqual(["承認待ち", "まだ", "無し"]); + const required = screen.getByLabelText("必須の工程"); + expect(within(required).getAllByRole("definition").map((x) => x.textContent)).toEqual(["まだ", "まだ"]); + }); + + it("仕上げの記録が届いた実行は、振り返りと棚卸しの終了コードと秒を出す", async () => { + show(`${sprints}/sr-0001`); + const required = await screen.findByLabelText("必須の工程"); + expect(within(required).getAllByRole("definition").map((x) => x.textContent)).toEqual(["終了コード 0・300 秒", "終了コード 0・240 秒"]); + }); + + it("課題の欄で工程の記録を絞り、新しい工程が上に並ぶ", async () => { + show(detail); + const log = await screen.findByRole("region", { name: "工程の記録" }); + const names = () => within(log).getAllByRole("button", { pressed: false }).filter((b) => b.className.includes("sprint-steps__row")).map((b) => b.querySelector("strong")!.textContent); + expect(names()).toEqual(["impl(実装)", "sprint-branch(実装)", "design(設計)", "design(設計)"]); + await userEvent.click(within(log).getByRole("button", { name: "#12" })); + expect(names()).toEqual(["impl(実装)", "design(設計)"]); + await userEvent.click(within(log).getByRole("button", { name: "スプリント全体" })); + expect(names()).toEqual(["sprint-branch(実装)"]); + }); + + it("工程を押すと右が詳細パネルになり、触ったファイル・決めたこと・作業の報告を出し、「概要に戻る」で承認の欄へ戻る", async () => { + show(detail); + const log = await screen.findByRole("region", { name: "工程の記録" }); + await userEvent.click(within(log).getByRole("button", { name: /^impl(実装)/ })); + const panel = await screen.findByRole("region", { name: "工程の詳細" }); + expect(screen.queryByRole("region", { name: "承認ゲート" })).toBeNull(); + expect(await within(panel).findByRole("link", { name: "src/service/handler.ts" })).toBeTruthy(); + expect(within(panel).getByText("+42 −7")).toBeTruthy(); + expect(within(panel).getByText(/見本の問い/)).toBeTruthy(); + expect(within(panel).getByRole("link", { name: "差分を GitHub で見る" }).getAttribute("href")).toContain("/compare/"); + await userEvent.click(within(panel).getByRole("button", { name: "作業の報告を開く" })); + expect(within(panel).getByText(/作業の報告(見本)/)).toBeTruthy(); + await userEvent.click(within(panel).getByRole("button", { name: "概要に戻る" })); + expect(screen.getByRole("region", { name: "承認ゲート" })).toBeTruthy(); + }); + + it("工程の中身を持たない工程は、各欄に「記録なし」を出す", async () => { + show(detail); + const log = await screen.findByRole("region", { name: "工程の記録" }); + await userEvent.click(within(log).getByRole("button", { name: /^sprint-branch/ })); + const panel = await screen.findByRole("region", { name: "工程の詳細" }); + expect(await within(panel).findAllByText("記録なし")).toHaveLength(3); + }); +}); + +// 現状固定テスト(I-007: StepPanel の分割の前に、詳細パネルの分岐を固定する) +describe("工程の詳細パネルの分岐(現状固定)", () => { + function withStep(patch: Partial): Api { + const api = createDummyApi(); + const original = api.stepDetails.bind(api); + api.stepDetails = async (id, sprintRunId, keys) => (await original(id, sprintRunId, keys)).map((s) => ({ ...s, ...patch })); + return api; + } + + async function openImpl(api: Api) { + show(detail, api); + const log = await screen.findByRole("region", { name: "工程の記録" }); + await userEvent.click(within(log).getByRole("button", { name: /^impl(実装)/ })); + return screen.findByRole("region", { name: "工程の詳細" }); + } + + it("読み込みに失敗すると、戻るボタンと失敗の知らせだけを出す", async () => { + const api = createDummyApi(); + api.stepDetails = async () => { + throw new ApiFailure(503, "unavailable", "x"); + }; + const panel = await openImpl(api); + expect(await within(panel).findByRole("alert")).toBeTruthy(); + expect(within(panel).getByRole("button", { name: "概要に戻る" })).toBeTruthy(); + expect(within(panel).queryByText("触ったファイル")).toBeNull(); + }); + + it("見出しの欄にステップ・終了コード・秒・費用・要約を並べ、費用が無いときは「—」", async () => { + const panel = await openImpl(withStep({ cost_usd: null, summary: "要約の見本", exit_code: 3, seconds: 1234 })); + await within(panel).findByText("要約の見本"); + expect(within(panel).getAllByRole("term").map((x) => x.textContent)).toEqual(["ステップ", "終了コード", "秒", "費用", "要約"]); + expect(within(panel).getAllByRole("definition").map((x) => x.textContent)).toEqual(["impl(実装)", "3", "1,234", "—", "要約の見本"]); + }); + + it("ファイルと決めたことが空のときは「ありません」を出し、差分のリンクが無ければ出さない", async () => { + const panel = await openImpl(withStep({ files: [], decisions: [], diff_url: null, output: null })); + expect(await within(panel).findByText("触ったファイルはありません")).toBeTruthy(); + expect(within(panel).getByText("決めたことはありません")).toBeTruthy(); + expect(within(panel).queryByRole("link")).toBeNull(); + expect(within(panel).getAllByText("記録なし")).toHaveLength(1); + }); + + it("URL の無いファイルは文字だけ、行数の無いファイルは「バイナリ」、省いた件数は「ほか N 件」", async () => { + const panel = await openImpl( + withStep({ + files: [ + { path: "a.bin", added: null, deleted: 1, url: null }, + { path: "b.ts", added: 1, deleted: 2, url: "https://example.test/b.ts" }, + ], + files_omitted: 5, + decisions: [{ question: "Q", answer: "A", by: "user" }], + }), + ); + expect(await within(panel).findByText("a.bin")).toBeTruthy(); + expect(within(panel).queryByRole("link", { name: "a.bin" })).toBeNull(); + expect(within(panel).getByText("バイナリ")).toBeTruthy(); + expect(within(panel).getByRole("link", { name: "b.ts" }).getAttribute("href")).toBe("https://example.test/b.ts"); + expect(within(panel).getByText("+1 −2")).toBeTruthy(); + expect(within(panel).getByText("ほか 5 件")).toBeTruthy(); + expect(within(panel).getByText(/Q → A/)).toBeTruthy(); + expect(within(panel).getByText("利用者")).toBeTruthy(); + }); + + it("ログの出力は「ログを開く」で開き、切った知らせを出し、「閉じる」で畳む", async () => { + const panel = await openImpl(withStep({ output: { kind: "log", text: "ログの本文", truncated: true } })); + const toggle = await within(panel).findByRole("button", { name: "ログを開く" }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(within(panel).queryByText("ログの本文")).toBeNull(); + await userEvent.click(toggle); + expect(within(panel).getByText("ログの本文")).toBeTruthy(); + expect(within(panel).getByText("長いため途中で切りました")).toBeTruthy(); + const close = within(panel).getByRole("button", { name: "閉じる" }); + expect(close.getAttribute("aria-expanded")).toBe("true"); + await userEvent.click(close); + expect(within(panel).queryByText("ログの本文")).toBeNull(); + expect(within(panel).getByRole("button", { name: "ログを開く" })).toBeTruthy(); + }); +}); + +describe("読み直し(受け入れ条件 10)", () => { + it("開いたまま新しい事象が来ると、読み直しの間隔の 2 倍以内に出る。読み直しが失敗しても前の表示は消えない", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const api = createDummyApi(); + let rows: SprintRunRow[] = [sprintRuns[0]!]; + let fail = false; + api.sprintRuns = async () => { + if (fail) throw new ApiFailure(503, "unavailable", "x"); + return { sprint_runs: rows }; + }; + show(sprints, api, createDummyAuth(), 1000); + expect(await screen.findByText("今の工程: impl")).toBeTruthy(); + rows = [{ ...sprintRuns[0]!, current_step: "base-sync" }]; + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(screen.getByText("今の工程: base-sync")).toBeTruthy(); + fail = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(screen.getByText("今の工程: base-sync")).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toMatch(/^読み直せませんでした(/); + }); + + it("詳細は動いている実行だけを間隔で読み直し、終わった実行は開いたときと「もう一度読む」だけで読む", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const api = createDummyApi(); + const details = api.sprintRunDetails.bind(api); + let calls = 0; + let state = "running"; + api.sprintRunDetails = async (...args: Parameters) => { + calls += 1; + return (await details(...args)).map((d) => ({ ...d, state })); + }; + show(detail, api, createDummyAuth(), 1000); + await screen.findAllByText(/sr-0003/); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); + expect(calls).toBe(2); + state = "done"; + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(calls).toBe(3); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(calls).toBe(3); + }); + + it("バックログは間隔では読み直さず、開いたときと「もう一度読む」だけで読む", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const api = createDummyApi(); + const backlogs = api.backlogs.bind(api); + let calls = 0; + api.backlogs = async (...args: Parameters) => { + calls += 1; + return backlogs(...args); + }; + show(`/accounts/${PERSONAL_ID}/repositories/101/backlog`, api, createDummyAuth(), 1000); + expect(await screen.findByRole("table", { name: "着手の順位" })).toBeTruthy(); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(calls).toBe(1); + await act(async () => { + screen.getByRole("button", { name: "もう一度読む" }).click(); + await vi.advanceTimersByTimeAsync(10); + }); + expect(calls).toBe(2); + }); +}); + +describe("401 の扱い(受け入れ条件 12)", () => { + it.each([sprints, detail, `/accounts/${PERSONAL_ID}/repositories/101/backlog`])("%s で API が 401 を返すと token を消してログインへ移る", async (path) => { + let token: string | null = "t"; + const auth: Auth = { ...createDummyAuth(), getToken: () => token, clearToken: () => (token = null) }; + const api = createApi("https://api.example.test", auth, async () => Response.json({ error: { code: "unauthorized", message: "x" } }, { status: 401 })); + show(path, api, auth); + await vi.waitFor(() => expect(screen.getByTestId("where").textContent).toBe("/login")); + expect(token).toBeNull(); + }); +}); + +describe("バックログ(受け入れ条件 15・16)", () => { + it("順位の表と棚卸しの記録を出し、書き込む操作を持たない", async () => { + show(`/accounts/${PERSONAL_ID}/repositories/101/backlog`); + const ranking = await screen.findByRole("table", { name: "着手の順位" }); + expect(within(ranking).getByText("#21 見本の課題 1")).toBeTruthy(); + expect(within(screen.getByRole("table", { name: "棚卸しの記録" })).getByText("順位を見直した(見本)")).toBeTruthy(); + expect(screen.queryAllByRole("button").filter((b) => !b.closest("header,nav"))).toEqual([]); + }); + + it("順位の表が無いリポジトリは「順位の表がありません」", async () => { + show(`/accounts/${PERSONAL_ID}/repositories/102/backlog`); + expect(await screen.findByText("順位の表がありません")).toBeTruthy(); + expect(screen.getByText("棚卸しの記録はまだありません")).toBeTruthy(); + }); +}); diff --git a/factory/web/src/poll.ts b/factory/web/src/poll.ts new file mode 100644 index 000000000..de8300dc7 --- /dev/null +++ b/factory/web/src/poll.ts @@ -0,0 +1,85 @@ +// 読み直し(#2172 の前提 7・決定 12)。画面を開いている間、設定の間隔で読み直す。押し出しの通知は使わない。 +// 見えていない間は読み直さず、前の読み直しが終わる前に次を打たない。読み直しが失敗しても前の表示は残す +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFailureHandler, useServices } from "./context.tsx"; + +export const DEFAULT_POLL_INTERVAL_MS = 30_000; + +export interface Polled { + data: T | null; + // 最初の読み込みの失敗の文(401 では null のままログインへ移る) + error: string | null; + // 読み直しが失敗した時刻(前の表示を残している)。成功すれば null に戻る + staleSince: string | null; + reload: () => void; +} + +// read が変わると最初から読み直す。enabled が偽の間は読まない。poll が偽なら間隔では読み直さず、開いたときと reload だけで読む +// (変わることの少ない画面で、1 回の読み出しが重いもの。バックログなど)。poll が関数なら、間隔のたびに今の data で決める +// (終わった実行の詳細など、data がもう変わらないと分かったら読み直さない) +export function usePolled(read: () => Promise, enabled: boolean, { poll = true }: { poll?: boolean | ((data: T | null) => boolean) } = {}): Polled { + const { pollIntervalMs } = useServices(); + const interval = pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const onFailure = useFailureHandler(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [staleSince, setStaleSince] = useState(null); + const [generation, setGeneration] = useState(0); + const hasData = useRef(false); + const latest = useRef(null); + // 関数の poll は描画のたびに作り直されるため、読み直しの effect を起こし直さないよう ref で読む + const pollOf = useRef(poll); + pollOf.current = poll; + const polling = poll !== false; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let running = false; + hasData.current = false; + latest.current = null; + setData(null); + setError(null); + setStaleSince(null); + const tick = async () => { + if (running) return; + running = true; + try { + const value = await read(); + if (cancelled) return; + hasData.current = true; + latest.current = value; + setData(value); + setError(null); + setStaleSince(null); + } catch (err) { + if (cancelled) return; + const message = onFailure(err); + if (message === null) return; + if (hasData.current) setStaleSince(new Date().toISOString()); + else setError(message); + } finally { + running = false; + } + }; + void tick(); + if (!polling) { + return () => { + cancelled = true; + }; + } + const timer = setInterval(() => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + const p = pollOf.current; + if (typeof p === "function" && !p(latest.current)) return; + void tick(); + }, interval); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [read, enabled, interval, onFailure, generation, polling]); + + const reload = useCallback(() => setGeneration((g) => g + 1), []); + return { data, error, staleSince, reload }; +} diff --git a/factory/web/src/screens.ts b/factory/web/src/screens.ts index e2c6e7c57..6bf1dec0c 100644 --- a/factory/web/src/screens.ts +++ b/factory/web/src/screens.ts @@ -46,7 +46,7 @@ export const SCREENS: readonly Screen[] = [ { id: "repositories", path: "/accounts/:accountId/repositories", roles: ALL_ROLES, nav: true, status: "available" }, { id: "modelCredential", path: "/accounts/:accountId/model-credential", roles: OWNERS, nav: true, status: "available" }, { id: "newSprint", path: "/accounts/:accountId/repositories/:repositoryId/sprints/new", roles: ALL_ROLES, nav: false, status: "available" }, - { id: "backlog", path: "/accounts/:accountId/repositories/:repositoryId/backlog", roles: ALL_ROLES, nav: false, status: "planned" }, + { id: "backlog", path: "/accounts/:accountId/repositories/:repositoryId/backlog", roles: ALL_ROLES, nav: false, status: "available" }, { id: "approvals", path: null, roles: ALL_ROLES, nav: false, status: "planned" }, { id: "billing", path: null, roles: OWNERS, nav: false, status: "planned" }, { id: "runners", path: null, roles: OWNERS, nav: false, status: "planned" }, diff --git a/factory/web/src/ui/ui.css b/factory/web/src/ui/ui.css index a05e71d20..3c2c71606 100644 --- a/factory/web/src/ui/ui.css +++ b/factory/web/src/ui/ui.css @@ -489,3 +489,76 @@ ul { content: none; } } + +/* スプリントの一覧と詳細(#2172) */ +.ui-muted { + display: block; + font-size: 0.875em; +} + +.phase-bars { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.phase-bars__bar { + display: inline-block; + width: 1.25rem; + height: 0.5rem; + border-radius: 2px; + border: 1px solid var(--color-primary); +} + +.phase-bars__bar--passed { + background: var(--color-primary); +} + +.phase-bars__bar--current { + background: var(--color-info-bg); +} + +.phase-text { + margin-inline-start: 0.5rem; +} + +.sprint-phases { + display: grid; + gap: 0.5rem; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); +} + +.sprint-detail { + display: grid; + gap: 1rem; +} + +@media (min-width: 1024px) { + .sprint-detail { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + } +} + +.sprint-steps__row { + display: block; + width: 100%; + min-height: 44px; + padding: 0.5rem; + text-align: start; + background: var(--color-base); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 4px; + cursor: pointer; +} + +.sprint-steps__row--selected { + border-color: var(--color-primary); +} + +.sprint-panel__output { + max-height: 24rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/factory/web/vite.config.ts b/factory/web/vite.config.ts index 26077262c..2362df161 100644 --- a/factory/web/vite.config.ts +++ b/factory/web/vite.config.ts @@ -3,9 +3,14 @@ import { defineConfig } from "vitest/config"; import { loadEnv } from "vite"; import react from "@vitejs/plugin-react"; -// 画面へ埋め込む変数は次の 4 つだけ。ほかの変数を画面へ入れない(docs/specifications/ndf-factory-accounts-and-plans.md の「環境変数」) +// 画面へ埋め込む変数は次の 5 つだけ。ほかの変数を画面へ入れない(docs/specifications/ndf-factory-accounts-and-plans.md の「環境変数」) const FACTORY_ROOT = fileURLToPath(new URL("..", import.meta.url)); +function pollInterval(raw: string | undefined): number { + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : 30_000; +} + export default defineConfig(({ mode }) => { const env = loadEnv(mode, FACTORY_ROOT, ""); const config = { @@ -13,6 +18,8 @@ export default defineConfig(({ mode }) => { cognitoClientId: env.COGNITO_CLIENT_ID ?? "", webUrl: env.FACTORY_WEB_URL ?? "", apiUrl: env.FACTORY_API_URL ?? "", + // 一覧と詳細の読み直しの間隔。正の整数でなければ既定の 30 秒 + pollIntervalMs: pollInterval(env.FACTORY_POLL_INTERVAL_MS), }; return { plugins: [react()], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json index 3fa013b33..5458e1dd6 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -1,7 +1,7 @@ { "version": 1, "path": "plugins/ndf/scripts/parallel.py", - "summary": "並列の計画(`.ndf/parallel/<計画名>/`)の下書き・検査・起動・進行の待ち・まとめた配布の提示と確定・レーンの配布の依頼と答え(#2436)。結果は統一 JSON レポートを 1 行で標準出力へ出す。", + "summary": "並列の計画(`.ndf/parallel/<計画名>/`)の下書き・検査・起動・進行の待ち・まとめた配布の提示と確定・レーンの配布の依頼と答え(#2436)。外のオーガナイザーのレーンと配布の実行は `--external` で置き場のファイルとやり取りする(#2439)。結果は統一 JSON レポートを 1 行で標準出力へ出す。", "args": { "options": [], "positionals": [], @@ -21,6 +21,7 @@ }, "await": { "options": [ + "--external", "--from", "--timeout" ], @@ -34,6 +35,12 @@ "name" ] }, + "compose": { + "options": [ + "--external" + ], + "positionals": [] + }, "confirm": { "options": [ "--take" @@ -59,7 +66,9 @@ }, "present": { "options": [ - "--approval" + "--approval", + "--external", + "--record" ], "positionals": [ "name", @@ -69,6 +78,7 @@ }, "request": { "options": [ + "--external", "--issues", "--lane", "--prs", @@ -105,6 +115,7 @@ "settle": { "options": [ "--done", + "--external", "--reason", "--rejected" ], @@ -151,7 +162,10 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_ESTIMATE_DIR", + "NDF_FACTORY_RUN", "NDF_MVV_STATE_DIR", + "NDF_ORGANIZER_DIR", + "NDF_ORGANIZER_LANE", "NDF_PARALLEL_MAX_PLANS", "NDF_PARALLEL_POLL", "NDF_PARALLEL_RELAY", @@ -166,6 +180,7 @@ "NDF_SUPERVISE_LIMIT_SLEEP", "NDF_SUPERVISE_MODEL", "NDF_SV_STATE_DIR", + "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json index 8bd45e6cb..d3019b025 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json @@ -25,6 +25,8 @@ "NDF_ESTIMATE_DIR", "NDF_LOOP_EXCLUDE", "NDF_MVV_STATE_DIR", + "NDF_ORGANIZER_DIR", + "NDF_ORGANIZER_LANE", "NDF_PARALLEL_MAX_PLANS", "NDF_PRESENTATION_DIR", "NDF_RELAY_DIR", @@ -37,6 +39,7 @@ "NDF_SUPERVISE_MODEL", "NDF_SV_STATE_DIR", "NDF_UPKEEP_STATE_DIR", + "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json index 97922ec6b..4d9ef14ef 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json @@ -19,6 +19,8 @@ "NDF_DEPS_VENV", "NDF_ESTIMATE_DIR", "NDF_MVV_STATE_DIR", + "NDF_ORGANIZER_DIR", + "NDF_ORGANIZER_LANE", "NDF_PARALLEL_MAX_PLANS", "NDF_PRESENTATION_DIR", "NDF_RELAY_MAX_NAMED", @@ -30,6 +32,7 @@ "NDF_SUPERVISE_LIMIT_SLEEP", "NDF_SUPERVISE_MODEL", "NDF_SV_STATE_DIR", + "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/env/NDF_FACTORY_RUN.json b/plugins/ndf/contracts/env/NDF_FACTORY_RUN.json new file mode 100644 index 000000000..97f46b4ae --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_FACTORY_RUN.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_FACTORY_RUN", + "set_by": "runtime", + "summary": "サービスの実行ジョブの中で 1 を立てる(#2439 の I68)。あるとき parallel.py init・start・run は 1 台の中のオーガナイザーを起動せずに断る(plugins/ndf/scripts/parallel.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json b/plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json new file mode 100644 index 000000000..e3ca97b63 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_ORGANIZER_DIR", + "set_by": "runtime", + "summary": "外のオーガナイザーとファイルでやり取りする置き場(#2439)。あるとき、このセッションは外のオーガナイザーのレーンで、本番の配布を自分で組まずに配布の依頼を書く。形は contracts/forms/parallel-external.json(plugins/ndf/scripts/lib/parallel_external.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_ORGANIZER_LANE.json b/plugins/ndf/contracts/env/NDF_ORGANIZER_LANE.json new file mode 100644 index 000000000..da91b47a9 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_ORGANIZER_LANE.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_ORGANIZER_LANE", + "set_by": "runtime", + "summary": "外のオーガナイザーのレーンの名前(#2439)。配布の依頼の id の頭になる。NDF_ORGANIZER_DIR と組で渡す(plugins/ndf/scripts/lib/parallel_external.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_VERSION.json b/plugins/ndf/contracts/env/NDF_VERSION.json new file mode 100644 index 000000000..2837bc459 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_VERSION.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_VERSION", + "set_by": "runtime", + "summary": "サービスの実行ジョブが入れた NDF の版(開発版は @<コミットの SHA>。#2439)。plugin.json の version と同じなら、配布の依頼の ndf_version へこの値を書く(plugins/ndf/scripts/lib/parallel_external.py)" +} diff --git a/plugins/ndf/contracts/forms/parallel-external.json b/plugins/ndf/contracts/forms/parallel-external.json new file mode 100644 index 000000000..c0d70399d --- /dev/null +++ b/plugins/ndf/contracts/forms/parallel-external.json @@ -0,0 +1,261 @@ +{ + "name": "parallel-external", + "summary": "外のオーガナイザーと、環境変数 NDF_ORGANIZER_DIR の置き場のファイルでやり取りする形(#2439)。レーンは requests/ に配布の依頼を書き、外のオーガナイザーは answers/・ranges/・batch.json を書き、配布の実行は presented/・progress/・results/ を書く。どちらの書き手も一時ファイルへ書いて名前を付け替える。外のオーガナイザーの側の読み手と書き手(サービスのランナーなど)も同じ例の組を読む", + "rule": "message.file は置き場からの相対パス、message.body は中身。requests/.json は #2436 の配布の依頼(parallel-request)に ndf_version(空でない文字列)を足した形で、ファイル名は id。answers/.json の result は released / rejected。ranges/<連番>.json は {sha, version, prs, lanes}。batch.json の step は present / represent / confirm:1 / confirm:2 / continue、lanes は {lane, requests:[{id, prs}], issues}。presented/<連番>-<提示>.json の k は 1 か 2 で、auto は真偽。progress/<連番>.json の stages は release_branch / develop_merged / prod_pr_opened / main_merged / tagged の並び。results/<連番>.json の result は released / failed", + "writers": [ + "plugins/ndf/scripts/lib/parallel_external.py" + ], + "readers": [ + "plugins/ndf/scripts/lib/parallel_external.py" + ], + "cases": [ + { + "message": { + "file": "requests/loop-l20261102-r000000000042.json", + "body": { + "kind": "request", + "id": "loop-l20261102-r000000000042", + "lane": "loop-l20261102", + "prs": [ + 2440 + ], + "issues": [ + 2439 + ], + "sprint_state": "/work/repo/.ndf/tmp/s1/8-release-request-state", + "at": "2026-11-02T13:00:00Z", + "ndf_version": "10.27.16" + } + }, + "valid": true + }, + { + "message": { + "file": "requests/loop-l20261102-r000000000042.json", + "body": { + "kind": "request", + "id": "loop-l20261102-r000000000042", + "lane": "loop-l20261102", + "prs": [ + 2440 + ], + "issues": [ + 2439 + ], + "sprint_state": "/work/repo/.ndf/tmp/s1/8-release-request-state", + "at": "2026-11-02T13:00:00Z" + } + }, + "valid": false + }, + { + "message": { + "file": "requests/loop-x-r000000000042.json", + "body": { + "kind": "request", + "id": "loop-l20261102-r000000000042", + "lane": "loop-l20261102", + "prs": [ + 2440 + ], + "issues": [ + 2439 + ], + "sprint_state": "/work/repo/.ndf/tmp/s1/8-release-request-state", + "at": "2026-11-02T13:00:00Z", + "ndf_version": "10.27.16" + } + }, + "valid": false + }, + { + "message": { + "file": "answers/loop-l20261102-r000000000042.json", + "body": { + "result": "released", + "version": "10.28.0", + "release": "https://github.com/o/r/pull/900", + "reason": null, + "at": "2026-11-02T14:00:00Z" + } + }, + "valid": true + }, + { + "message": { + "file": "answers/loop-l20261102-r000000000042.json", + "body": { + "result": "failed", + "version": null, + "release": null, + "reason": null, + "at": "2026-11-02T14:00:00Z" + } + }, + "valid": false + }, + { + "message": { + "file": "ranges/1.json", + "body": { + "sha": "0123abcd", + "version": "10.28.0", + "prs": [ + 2440 + ], + "lanes": [ + "loop-l20261102" + ] + } + }, + "valid": true + }, + { + "message": { + "file": "batch.json", + "body": { + "seq": 1, + "step": "present", + "lanes": [ + { + "lane": "loop-l20261102", + "requests": [ + { + "id": "loop-l20261102-r000000000042", + "prs": [ + 2440 + ] + } + ], + "issues": [ + 2439 + ] + }, + { + "lane": "run-0123456789abcdef", + "requests": [], + "issues": [ + 2441 + ] + } + ], + "presentations": [], + "choices": [ + "continue", + "reject" + ] + } + }, + "valid": true + }, + { + "message": { + "file": "batch.json", + "body": { + "seq": 1, + "step": "confirm:3", + "lanes": [], + "presentations": [], + "choices": [] + } + }, + "valid": false + }, + { + "message": { + "file": "presented/1-1.json", + "body": { + "seq": 1, + "k": 1, + "sha": "0123abcd", + "version": "10.28.0", + "prs": [ + 2440, + 2442 + ], + "lanes": [ + { + "lane": "loop-l20261102", + "requested": true + }, + { + "lane": "run-0123456789abcdef", + "requested": false + } + ], + "added_prs": [], + "auto": false + } + }, + "valid": true + }, + { + "message": { + "file": "progress/1.json", + "body": { + "seq": 1, + "stages": [ + "release_branch", + "prod_pr_opened", + "main_merged" + ] + } + }, + "valid": true + }, + { + "message": { + "file": "progress/1.json", + "body": { + "seq": 1, + "stages": [ + "deployed" + ] + } + }, + "valid": false + }, + { + "message": { + "file": "results/1.json", + "body": { + "seq": 1, + "result": "released", + "version": "10.28.0", + "prod_pr_url": "https://github.com/o/r/pull/900", + "sha": "0123abcd", + "prs": [ + 2440, + 2442 + ], + "lanes": [ + "loop-l20261102", + "run-0123456789abcdef" + ], + "reason": null + } + }, + "valid": true + }, + { + "message": { + "file": "results/1.json", + "body": { + "seq": 1, + "result": "failed", + "version": "10.28.0", + "prod_pr_url": null, + "sha": "0123abcd", + "prs": [ + 2440 + ], + "lanes": [ + "loop-l20261102" + ], + "reason": "本番のプランが完了しなかった" + } + }, + "valid": true + } + ] +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index 5de596724..f2b79e74f 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -104,6 +104,7 @@ | `NDF_DEPS_VENV` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/instructions-check.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `NDF_ESTIMATE_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | | `NDF_EXTERNAL_AI_TMP_DIR` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | +| `NDF_FACTORY_RUN` | runtime | `plugins/ndf/scripts/parallel.py` | | `NDF_HOOK_ENV` | user | `plugins/ndf/scripts/worktree-session.sh` | | `NDF_HOOK_PYTHON` | user | `plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/token-guard.sh`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/scripts/worktree-guard.sh` | | `NDF_HOOK_REEXEC` | user | `plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/wait-notify.py` | @@ -112,6 +113,8 @@ | `NDF_LOOP_EXCLUDE` | runtime | `plugins/ndf/scripts/sprint-state.py` | | `NDF_MVV_CLAUDE` | user | `plugins/ndf/scripts/mvv-gate.py` | | `NDF_MVV_STATE_DIR` | user | `plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_ORGANIZER_DIR` | runtime | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_ORGANIZER_LANE` | runtime | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_PARALLEL_MAX_PLANS` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_PARALLEL_POLL` | user | `plugins/ndf/scripts/parallel.py` | | `NDF_PARALLEL_RELAY` | user | `plugins/ndf/scripts/parallel.py` | @@ -148,6 +151,7 @@ | `NDF_UPKEEP_STATE_DIR` | user | `plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | | `NDF_USAGE_KIND` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | | `NDF_USAGE_SOURCE` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | +| `NDF_VERSION` | runtime | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_WHOLE_RUNS_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | | `NDF_WORKTREE_BASE` | user | `plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py` | | `PATH` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-verification-steps.py` | diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py new file mode 100644 index 000000000..bdc1eb10f --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -0,0 +1,29 @@ +"""並列の計画の問いへ答えた後に、だれを起こすか(#2436 の 6c・6f)。`parallel.py answer` だけが使う。 + +`parallel_run`(耐久ワークフローの本体)を読むため、レーンの側の `parallel_lane` から分けて置く(レーンの側の入口が +`parallel_run` の環境変数を読まないように)。 +""" + +from __future__ import annotations + +from pathlib import Path + +import parallel_lane as pl + + +def owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: + """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは答えを待って止まった持ち主の + restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。同じ原因としてまとめた問い(`merged`)は、 + 代表だけでなく全持ち主をそれぞれの依頼の id で調べ、起こす行を `; ` でつなぐ(異常系 5)。""" + import durable + import parallel_run as pr_run + + items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] + q = next((q for q in items if q.get("id") == rid), {}) + if q.get("kind") in ("stall", "helper", "spawn") or not q.get("lane"): + return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) + ls = lambda: pr_run.relay(main, "ls", "--json") # noqa: E731 + owners = [(q["lane"], rid), *((m.get("lane"), m.get("id")) for m in q.get("merged") or [] if m.get("lane"))] + lanes = [lane for lane, each in owners if pl.lane_waits(main, name, lane, str(each or rid), ls)] + lanes = list(dict.fromkeys(lanes)) + return "; ".join(f"python3 {pr_run.RELAY} restart {lane}" for lane in lanes) or None diff --git a/plugins/ndf/scripts/lib/parallel_external.py b/plugins/ndf/scripts/lib/parallel_external.py new file mode 100644 index 000000000..1dfe369dc --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_external.py @@ -0,0 +1,403 @@ +"""外のオーガナイザーとファイルでやり取りする切り替え口(#2439。契約 `contracts/forms/parallel-external.json`)。 + +環境変数 `NDF_ORGANIZER_DIR` があるとき、このセッションは外のオーガナイザーのレーン(名前は `NDF_ORGANIZER_LANE`)で、 +本番の配布を自分で組まずに置き場へ配布の依頼を書く。外のオーガナイザーが答え・配布済みの範囲・まとめた配布の中身を +置き場へ書き、配布の実行は提示・届いた段・結果を書き返す。どちらの書き手も一時ファイルへ書いて名前を付け替え、 +読み手は名前の付いたファイルだけを読む。 + +```text +/ +├── requests/<依頼の id>.json # レーンが書く +├── answers/<依頼の id>.json # 外のオーガナイザーが書く +├── ranges/<連番>.json # 外のオーガナイザーが書く +├── batch.json # 外のオーガナイザーが書く(配布の実行だけ) +├── presented/<連番>-<提示>.json # 配布の実行が書く +├── progress/<連番>.json # 配布の実行が書く +├── results/<連番>.json # 配布の実行が書く +└── releases/<連番>/<提示>/ # 配布の実行の提示のプランと本番のプラン +``` +""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Callable, Optional + +DIR_ENV = "NDF_ORGANIZER_DIR" +LANE_ENV = "NDF_ORGANIZER_LANE" +# 外のオーガナイザーのレーンの計画名の代わり。`parallel.py <副命令> --external` の形にそのままなる +EXTERNAL = "--external" +STEPS = ("present", "represent", "confirm:1", "confirm:2", "continue") +STAGES = ("release_branch", "develop_merged", "prod_pr_opened", "main_merged", "tagged") + + +def organizer_dir() -> Optional[Path]: + v = os.environ.get("NDF_ORGANIZER_DIR") + return Path(v) if v else None + + +def organizer_lane() -> Optional[str]: + return os.environ.get("NDF_ORGANIZER_LANE") or None + + +def is_external(name: Optional[str]) -> bool: + return name == EXTERNAL + + +def put_json(path: Path, data) -> Path: + """一時ファイルへ書いて名前を付け替える(読み手が書きかけを読まない)。""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=path.parent) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n") + os.replace(tmp, path) + return path + + +def read_json(path: Path, default=None): + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return default + + +def utc_stamp() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def session_ndf_version() -> str: + """このセッションの NDF の版(plugin.json)。読めなければ空。 + + サービスの実行ジョブは入れた版を `NDF_VERSION` で渡す。開発版は `@<コミットの SHA>` で、plugin.json の + version だけでは固定したコミットが落ち、配布の実行が無い開発版のタグを取りに行くため、version が同じならそちらを返す。""" + got = read_json(Path(__file__).resolve().parents[2] / ".claude-plugin" / "plugin.json", {}) or {} + version = str(got.get("version") or "") + pinned = os.environ.get("NDF_VERSION") or "" + if version and "@" in pinned and pinned.split("@", 1)[0] == version: + return pinned + return version + + +# ---- レーンの側 ---- + + +def write_request_file(d: Path, rid: str, lane: str, prs: list[int], issues: list[int], state: Path) -> dict: + """配布の依頼 `requests/.json`(#2436 の依頼の形に `ndf_version` を足す)。同じ id の打ち直しは上書きで同じ中身。""" + msg = { + "kind": "request", + "id": rid, + "lane": lane, + "prs": list(prs), + "issues": list(issues), + "sprint_state": str(state), + "at": utc_stamp(), + "ndf_version": session_ndf_version(), + } + old = read_json(Path(d) / "requests" / f"{rid}.json") + if isinstance(old, dict) and old.get("prs") == msg["prs"]: + return old # 打ち直しでは時刻を変えない(送り直しで同じ依頼になる) + put_json(Path(d) / "requests" / f"{rid}.json", msg) + return msg + + +def read_answer_file(d: Path, rid: str) -> Optional[dict]: + got = read_json(Path(d) / "answers" / f"{rid}.json") + return got if isinstance(got, dict) else None + + +def answer_files_of(d: Path, ids: list[str]) -> dict[str, dict]: + """`parallel_lane.answers_of` と同じ形({id: {"answer", "defer"}})。外のオーガナイザーは知らせを書かない。""" + return {i: {"answer": read_answer_file(d, i), "defer": None} for i in ids} + + +def range_version(d: Path, prs: list[int]) -> Optional[str]: + """`prs` を含む配布済みの範囲 `ranges/<連番>.json` の版(無ければ None)。""" + found = None + rd = Path(d) / "ranges" + for p in sorted(rd.glob("*.json"), key=lambda x: int(x.stem) if x.stem.isdigit() else 0) if rd.is_dir() else []: + got = read_json(p, {}) or {} + if set(prs) & set(got.get("prs") or []): + found = got.get("version") + return found + + +# ---- 配布の実行の側 ---- + + +def read_batch(d: Path) -> dict: + got = read_json(Path(d) / "batch.json") + if not isinstance(got, dict) or got.get("step") not in STEPS or not isinstance(got.get("seq"), int): + raise ValueError(f"batch.json が無いか形が違う(置き場: {d})") + return got + + +def requests_of(batch: dict) -> list[dict]: + """まとめた配布の依頼(`parallel_release.map_lanes` が読む形 {id, lane, prs})。""" + return [ + {"id": r["id"], "lane": ln["lane"], "prs": list(r.get("prs") or [])} + for ln in batch.get("lanes") or [] + for r in ln.get("requests") or [] + ] + + +def plan_of(batch: dict): + """まとめた配布のレーンを、`parallel_release.map_lanes` が読む並列の計画の形にする(依頼の無いレーンは課題で対応づける)。""" + import parallel_plan as pp + + lanes = [ + pp.Lane.model_construct( + name=ln["lane"], + input=pp.SprintInput.model_construct(kind="sprint", issues=list(ln.get("issues") or []), args=[]), + plans=1, + touches=[], + ) + for ln in batch.get("lanes") or [] + ] + return pp.ParallelPlan.model_construct(version=1, name=EXTERNAL, rules=[], lanes=lanes) + + +def batch_release_dir(d: Path, seq: int, k: int) -> Path: + return Path(d) / "releases" / str(seq) / str(k) + + +def step_k(step: str, batch: dict) -> int: + """手の提示の番号。present は 1、represent は 2、confirm: は k、continue は最後の提示。""" + if step == "present": + return 1 + if step == "represent": + return 2 + if step.startswith("confirm:"): + return int(step.split(":", 1)[1]) + ks = [int(p.get("k") or 0) for p in batch.get("presentations") or []] + return max(ks) if ks else 1 + + +def compose(main: Path, d: Path, batch: dict, supervise: str, parallel: str, version_of: Callable, builder=None) -> dict: + """まとめた配布の手(`batch.json` の `step`)ごとに流すものを返す(#2436 の一時レーンの `helper` と同じ形)。 + + - present / represent: 提示 k の提示のプランと本番のプランを `releases/<連番>//` に作り、`pipeline` は提示のプラン → 本番のプラン + - confirm:: 答えの記録 → 提示 k のプランを `approve` から → 提示 k の本番のプラン(確定していない提示の本番のプランは流さない) + - continue: 止まった本番のプランを再開の手順で続ける""" + import parallel_release as pr + + step, seq = batch["step"], batch["seq"] + k = step_k(step, batch) + rd = batch_release_dir(d, seq, k) + out = {"seq": seq, "k": k, "step": step, "dir": str(rd), "record": f"{parallel} present --external {seq} {k} --record"} + if step in ("present", "represent"): + version = version_of(Path(main)) + if not version: + raise ValueError("出す版を決められない(ベースブランチの配るプラグインの plugin.json の版を読めない)") + rd.mkdir(parents=True, exist_ok=True) + put_json(rd / "version.json", {"version": version}) + present_plan, release_plan = (builder or pr.plan_parallel_release)(main, EXTERNAL, rd, version) + put_json(rd / "present.json", present_plan) + put_json(rd / "release.json", release_plan) + if not (rd / "answer.json").exists(): + put_json(rd / "answer.json", pr.answer_state(Path(main))) + done = rd / "done.json" + return { + **out, + "pipeline": f"{pr.pipeline_cmd(supervise, rd)} && {supervise} wait {done}", + "settle": f"{parallel} settle --external {seq} --done {done}", + } + done = rd / "release-done.json" + if step.startswith("confirm:"): + gate = f"python3 {Path(__file__).resolve().parents[1] / 'sprint-state.py'} gate {rd / 'answer.json'} '関門 2' --what 'まとめた配布 {seq} の提示 {k}' --by user --outcome approved" + steps = [gate, f"{supervise} run {rd / 'present.json'} --from approve", f"{supervise} queue {rd / 'release.json'} --done {done}"] + return { + **out, + "pipeline": " && ".join([*steps, f"{supervise} wait {done}"]), + "settle": f"{parallel} settle --external {seq} --done {done}", + } + # continue は前に実際に起動したキューの記録から続ける。confirm は release-done.json に書き、present / represent を + # MVV 判定が通して本番まで流れたときは done.json にしか無い(confirm は present の後なので release-done.json を先に見る) + if not done.exists() and (rd / "done.json").exists(): + done = rd / "done.json" + return { + **out, + "pipeline": f"{supervise} queue --resume {done} && {supervise} wait {done}", + "settle": f"{parallel} settle --external {seq} --done {done}", + } + + +def present_batch(main: Path, d: Path, batch: dict, seq: int, k: int, approval: Path, closes_of=None) -> dict: + """提示のプランの中で、範囲の Pull Request をレーンへ対応づけて `releases/<連番>//presented.json` を書く(#2436 と同じ関数)。""" + import parallel_release as pr + + rd = batch_release_dir(d, seq, k) + first = read_json(batch_release_dir(d, seq, 1) / "presented.json") if k != 1 else None + kwargs = {"closes_of": closes_of} if closes_of else {} + return pr.present(main, plan_of(batch), approval, requests_of(batch), rd, first, **kwargs) + + +def record_presented(d: Path, seq: int, k: int, auto: bool) -> dict: + """提示を外のオーガナイザーへ書き返す `presented/<連番>-.json`。承認ゲート 2 で止まったら auto は偽、MVV 判定が通したら真。""" + rd = batch_release_dir(d, seq, k) + got = read_json(rd / "presented.json") + if not isinstance(got, dict) or not got.get("sha"): + raise ValueError(f"提示 {seq}-{k} の presented.json が無い") + version = (read_json(rd / "version.json", {}) or {}).get("version") or "" + added = (got.get("added") or {}).get("lanes") or {} + data = { + "seq": seq, + "k": k, + "sha": got["sha"], + "version": version, + "prs": list(got.get("prs") or []), + "lanes": [{"lane": n, "requested": bool(r.get("requested"))} for n, r in sorted((got.get("lanes") or {}).items())], + "added_prs": sorted({n for r in added.values() for n in r.get("prs") or []} | set((got.get("added") or {}).get("orphans") or [])), + "auto": bool(auto), + } + put_json(Path(d) / "presented" / f"{seq}-{k}.json", data) + return data + + +def stages_of(root: Path, version: str, run=subprocess.run) -> list[str]: + """本番の配布が届いた段を GitHub で読む(head が `release/v<版>` の `main` 宛ての Pull Request)。""" + if not version: + return [] + head = f"release/v{version}" + got = run( + ["gh", "pr", "list", "--head", head, "--base", "main", "--state", "all", "--json", "state,url"], + cwd=root, + capture_output=True, + text=True, + ) + try: + pulls = json.loads(got.stdout or "[]") if got.returncode == 0 else [] + except ValueError: + pulls = [] + if any(p.get("state") == "MERGED" for p in pulls): + return ["release_branch", "prod_pr_opened", "main_merged"] + if pulls: + return ["release_branch", "prod_pr_opened"] + br = run(["git", "ls-remote", "--exit-code", "--heads", "origin", head], cwd=root, capture_output=True, text=True) + return ["release_branch"] if br.returncode == 0 else [] + + +def prod_pr_url(root: Path, version: str, run=subprocess.run) -> Optional[str]: + got = run( + ["gh", "pr", "list", "--head", f"release/v{version}", "--base", "main", "--state", "merged", "--json", "url", "-q", ".[0].url"], + cwd=root, + capture_output=True, + text=True, + ) + url = (got.stdout or "").strip() + return url if got.returncode == 0 and url else None + + +def mvv_passed(rd: Path) -> bool: + """提示の答えの記録(`answer.json`)に、MVV 判定が承認ゲート 2 を通した記録(by: mvv)があるか。 + + presented.json を書いた後に MVV 判定そのものが落ちた提示を、自動で承認したと書き返さないために見る。""" + from gate_answer import GATE_2 + + gates = (read_json(Path(rd) / "answer.json", {}) or {}).get("gates") or [] + return any(isinstance(g, dict) and g.get("name") == GATE_2 and g.get("by") == "mvv" for g in gates) + + +def settle_batch(main: Path, d: Path, batch: dict, seq: int, done: dict, reason: Optional[str] = None, run=subprocess.run) -> dict: + """本番のプランの done とリリース記録から、届いた段 `progress/<連番>.json` と結果 `results/<連番>.json` を書く。 + + 結果は done の全項目が完了なら released、承認ゲートで止まっていれば ValueError(書かない)、ほかは failed。 + MVV 判定が通して承認ゲート 2 で止まらなかった提示は、ここで `presented/` を auto 真で書く。""" + results = [str(i.get("result")) for i in done.get("items") or [] if isinstance(i, dict)] + if "関門" in results: + raise ValueError("本番のプランが承認ゲートで止まっている") + k = step_k(batch.get("step") or "continue", batch) + rd = batch_release_dir(d, seq, k) + version = (read_json(rd / "version.json", {}) or {}).get("version") or done.get("version") or "" + if not (Path(d) / "presented" / f"{seq}-{k}.json").exists() and (rd / "presented.json").exists() and mvv_passed(rd): + record_presented(d, seq, k, auto=True) + presented = read_json(rd / "presented.json", {}) or {} + stages = stages_of(main, version, run) + put_json(Path(d) / "progress" / f"{seq}.json", {"seq": seq, "stages": stages}) + released = bool(results) and all(r == "完了" for r in results) + data = { + "seq": seq, + "result": "released" if released else "failed", + "version": version or None, + "prod_pr_url": prod_pr_url(main, version, run) if released else None, + "sha": presented.get("sha"), + "prs": list(presented.get("prs") or []), + "lanes": sorted((presented.get("lanes") or {}).keys()), + "reason": None if released else (reason or "本番のプランが完了しなかった"), + } + put_json(Path(d) / "results" / f"{seq}.json", data) + return data + + +# ---- 形の検査(契約 `contracts/forms/parallel-external.json` の例の組) ---- + +_INTS = lambda v: isinstance(v, list) and all(isinstance(x, int) and not isinstance(x, bool) for x in v) # noqa: E731 + + +def _request_errors(name: str, body: dict) -> list[str]: + import parallel_lane as pl + + errs = pl.request_errors(body) + if not isinstance(body.get("ndf_version"), str) or not body.get("ndf_version"): + errs.append("ndf_version が空でない文字列でない") + if name != f"{body.get('id')}.json": + errs.append("ファイル名が依頼の id でない") + return errs + + +def _answer_errors(body: dict) -> list[str]: + errs: list[str] = [] + if body.get("result") not in ("released", "rejected"): + errs.append("result が released / rejected でない") + if not isinstance(body.get("at"), str): + errs.append("at が文字列でない") + return errs + + +def _batch_errors(body: dict) -> list[str]: + errs: list[str] = [] + if body.get("step") not in STEPS or not isinstance(body.get("seq"), int): + errs.append("step か seq の形が違う") + for ln in body.get("lanes") or []: + if ( + not isinstance(ln.get("lane"), str) + or not _INTS(ln.get("issues")) + or not all(_INTS(r.get("prs")) for r in ln.get("requests") or []) + ): + errs.append("lanes の形が違う") + return errs + + +def file_errors(message: object) -> list[str]: + """置き場のファイル 1 つ({file, body})の形の誤り。空なら形に合う。""" + if not isinstance(message, dict) or not isinstance(message.get("body"), dict) or not isinstance(message.get("file"), str): + return ["{file, body} の形でない"] + path, body = message["file"], message["body"] + top, _, name = path.partition("/") + errs: list[str] = [] + if top == "requests": + return _request_errors(name, body) + if top == "answers": + errs.extend(_answer_errors(body)) + elif top == "ranges": + if not _INTS(body.get("prs")) or not isinstance(body.get("lanes"), list): + errs.append("prs が整数の配列でないか lanes が配列でない") + elif path == "batch.json": + errs.extend(_batch_errors(body)) + elif top == "presented": + if body.get("k") not in (1, 2) or not isinstance(body.get("auto"), bool) or not _INTS(body.get("prs")): + errs.append("k・auto・prs の形が違う") + elif top == "progress": + if not isinstance(body.get("stages"), list) or any(s not in STAGES for s in body["stages"]): + errs.append("stages に知らない段がある") + elif top == "results": + if body.get("result") not in ("released", "failed") or not _INTS(body.get("prs")): + errs.append("result か prs の形が違う") + else: + errs.append(f"知らないファイル: {path}") + return errs diff --git a/plugins/ndf/scripts/lib/parallel_external_cli.py b/plugins/ndf/scripts/lib/parallel_external_cli.py new file mode 100644 index 000000000..0d99c0407 --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_external_cli.py @@ -0,0 +1,114 @@ +"""`parallel.py` の `--external` の副命令の本体(#2439。置き場のファイルは `parallel_external.py`)。 + +外のオーガナイザーのレーンと配布の実行(環境変数 `NDF_ORGANIZER_DIR`)は、計画名の代わりに `--external` を付けて +置き場のファイルでやり取りする。 + +| 副命令 | 役割 | +| --- | --- | +| `request --external --lane <名> --state <パス> [--prs ...] [--issues ...]` | 配布の依頼を `requests/.json` へ書く(10)か、すでに `main` にあれば書かない(0) | +| `await --external --from <パス>` | `answers/.json` を読む(released 0・rejected 10・まだ無い 3) | +| `compose --external` | `batch.json` の手(`step`)ごとに流すもの(`pipeline`・`record`・`settle`)を返す | +| `present --external <連番> <提示> (--approval <承認資料> \\| --record)` | 範囲をレーンへ対応づける。`--record` は承認ゲート 2 で止まった提示を `presented/` へ書く | +| `settle --external <連番> --done ` | 届いた段を `progress/`、結果を `results/` へ書く | + +サービスの実行ジョブ(環境変数 `NDF_FACTORY_RUN`)では `init`・`start`・`run` を断る(1 台の中のオーガナイザーを起動しない)。 + +どの関数も、結果を出して終える `reply`(`parallel.py` の同名の関数)を受け取り、それで終える。 +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Callable + +import parallel_external as pe +import parallel_lane as pl + + +def refuse_in_factory_run(reply: Callable) -> None: + """サービスの実行ジョブ(`NDF_FACTORY_RUN`)の中では 1 台の中のオーガナイザーを起動しない(#2439 の I68。2 つのオーガナイザーは排他)。""" + if os.environ.get("NDF_FACTORY_RUN"): + reply( + "stopped", "サービスの実行ジョブの中では並列の計画を起動しない(外のオーガナイザーが配布をまとめる)", code=1, factory_run=True + ) + + +def external_dir(reply: Callable) -> Path: + d = pe.organizer_dir() + if d is None: + reply("stopped", f"--external には環境変数 {pe.DIR_ENV} が要る", code=1) + return d + + +def present_external(main: Path, a, reply: Callable) -> None: + d = external_dir(reply) + try: + batch = pe.read_batch(d) + if a.record: + got = pe.record_presented(d, a.n, a.k, auto=False) + reply("ok", f"提示 {a.n}-{a.k} を外のオーガナイザーへ書いた({got['sha'][:12]} まで)", sha=got["sha"], prs=got["prs"]) + if not a.approval: + reply("stopped", "--approval か --record が要る", code=1) + got = pe.present_batch(main, d, batch, a.n, a.k, Path(a.approval)) + except ValueError as exc: + reply("stopped", f"提示を作れない: {exc}", code=1) + except OSError as exc: + reply("stopped", f"読めない: {exc}", code=2) + reply( + "ok", + f"提示 {a.n}-{a.k}: {got['sha'][:12]} まで(Pull Request {len(got['prs'])} 本)", + [{"lane": n, **r} for n, r in got["lanes"].items()], + sha=got["sha"], + orphans=got["orphans"], + ) + + +def compose_external(main: Path, reply: Callable) -> None: + """配布の実行(`--parallel-helper --external`)が `batch.json` の手で流すもの(#2439)。""" + from parallel_run import dev_version + from supervise_lib.paths import PARALLEL_PY, SELF + + d = external_dir(reply) + try: + task = pe.compose(main, d, pe.read_batch(d), f"python3 {SELF}", PARALLEL_PY, dev_version) + except ValueError as exc: + reply("stopped", f"まとめた配布を組めない: {exc}", code=1) + except OSError as exc: + reply("stopped", f"書けない: {exc}", code=2) + reply("ok", f"まとめた配布 {task['seq']} の手 {task['step']}(提示 {task['k']})", **task) + + +def settle_external(main: Path, a, reply: Callable) -> None: + d = external_dir(reply) + if a.rejected or not a.done: + reply("stopped", "--external の settle は --done が要る(差し戻しは外のオーガナイザーが決める)", code=1) + try: + done = json.loads(Path(a.done).read_text(encoding="utf-8")) + got = pe.settle_batch(main, d, pe.read_batch(d), a.n, done, a.reason) + except ValueError as exc: + reply("stopped", f"結果を書かない: {exc}", code=4) + except OSError as exc: + reply("stopped", f"done を読めない: {exc}", code=2) + reply("ok", f"配布 {a.n} の結果 {got['result']} を外のオーガナイザーへ書いた", result=got["result"], version=got["version"]) + + +def request_external(main: Path, a, reply: Callable, parallel_py: Path) -> None: + d = external_dir(reply) + if a.prs and pl.merged_on_production(main, a.prs): + version = pe.range_version(d, a.prs) + pl.write_request(Path(a.state), {"result": "released", "version": version, "prs": a.prs}) + reply("ok", "自分の Pull Request はすでに本番チャネルにある。依頼を書かずに続きへ進む", result="released", version=version) + rid = pl.lane_request_id(Path(a.state), a.lane) + pe.write_request_file(d, rid, a.lane, a.prs, a.issues, Path(a.state)) + pl.write_request(Path(a.state), {"id": rid, "plan": pe.EXTERNAL, "lane": a.lane, "prs": a.prs}) + nxt = f"python3 {parallel_py} await --external --from {Path(a.state) / 'work' / 'request.txt'}" + reply( + "gate", + f"配布の依頼 {rid} を外のオーガナイザーへ書いた(配布待ち)。答えは parallel.py await --external で読む", + code=10, + next_step=nxt, + id=rid, + result="requested", + ) diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index a42a470ef..f8f9a9da0 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -1,6 +1,10 @@ """並列の計画のレーンの側(#2436): 自分が属する計画の判定(I12)・すでに配布済みかの判定(I15)・配布の依頼を送る・ 答えを読む(待たずに読むのと待つのの両方)・プランの本数を返す。 +環境変数 `NDF_ORGANIZER_DIR` があれば、外のオーガナイザーのレーンとして判定し、計画名の代わりに `--external` を返す +(#2439。依頼と答えは置き場のファイルで受け渡す。`parallel_external.py`)。外のレーンは設計の承認と承認ゲートの外の問いを +依頼にせず、今どおり止まって人へ戻す。 + 耐久の記録は外のプロセスとして `durable.py` の `send_to`・`event_of`・`status_of` で開く(DBOS を起動しない)。 DBOS を読まずに済む判定(計画の有無・本数)は、耐久の記録のファイルが無ければ DBOS を import しない。 """ @@ -16,6 +20,7 @@ from pathlib import Path from typing import Optional +import parallel_external as pe import parallel_plan as pp from parallel_files import helper_plan_of, lane_name_in_plans, plans_in # noqa: F401 ファイルだけで読む判定(問いの hook も使う) @@ -48,8 +53,22 @@ def run_status(main: Path, name: str) -> Optional[str]: return durable.status_from(KIND, record_identity(main, name), workflow_id(name)) +def external_lane() -> Optional[tuple[str, pp.Lane]]: + """外のオーガナイザーのレーンなら (`--external`, レーン)。名前は `NDF_ORGANIZER_LANE`(#2439)。""" + if pe.organizer_dir() is None: + return None + name = pe.organizer_lane() or "external" + lane = pp.Lane.model_construct( + name=name, input=pp.SprintInput.model_construct(kind="sprint", issues=[], args=[]), plans=QUEUE_MAX, touches=[] + ) + return pe.EXTERNAL, lane + + def lane_of(main: Path, lane: Optional[str] = None, status=run_status) -> Optional[tuple[str, pp.Lane]]: - """`NDF_RELAY_NAME` の名前が、耐久ワークフローが終わっていない計画のレーンにあれば (計画名, レーン)。無ければ None(I12)。""" + """`NDF_RELAY_NAME` の名前が、耐久ワークフローが終わっていない計画のレーンにあれば (計画名, レーン)。無ければ None(I12)。 + 外のオーガナイザーのレーン(`NDF_ORGANIZER_DIR`)なら、名前を渡さない判定で (`--external`, レーン) を返す(#2439)。""" + if lane is None and (ext := external_lane()): + return ext lane = lane if lane is not None else os.environ.get("NDF_RELAY_NAME") if not lane: return None @@ -208,7 +227,7 @@ def send_release_request(main: Path, name: str, lane: str, prs: list[int], issue "prs": list(prs), "issues": list(issues), "sprint_state": str(state), - "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "at": pe.utc_stamp(), } durable.send_to(KIND, record_identity(main, name), wid, msg, "inbox", f"{wid}:inbox:{rid}") return rid @@ -240,6 +259,9 @@ def write_request(state: Path, data: dict) -> Path: def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: """依頼ごとの {"answer": `answer-<依頼>`, "defer": `defer-<依頼>`} を待たずに読む(周回の境で読む。#2436 の 6a・6e)。 耐久の記録が無ければ、どれも答えの無いものとして返す(DBOS を import しない)。""" + if pe.is_external(name): + d = pe.organizer_dir() + return pe.answer_files_of(d, ids) if d else {i: {"answer": None, "defer": None} for i in ids} out = {i: {"answer": None, "defer": None} for i in ids} if not ids or not record_exists(main, name): return out @@ -258,7 +280,9 @@ def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status=run_status) -> Optional[tuple[str, str]]: """問いを送ってよい送り手か: 動いている計画のレーン(`lane_of`)か、役割のファイルが `helper` で、その計画の - 耐久ワークフローが終わっていない一時レーン。(計画名, 送り手の名前) か None。""" + 耐久ワークフローが終わっていない一時レーン。(計画名, 送り手の名前) か None。外のオーガナイザーのレーンは問いを送らない(#2439)。""" + if pe.organizer_dir() is not None: + return None got = lane_of(main, name, status) if name else None if got: return got[0], name @@ -268,11 +292,10 @@ def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status= return None -def _utc_stamp() -> str: - return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - - def _send_inbox(main: Path, name: str, msg: dict, key: str) -> None: + if pe.is_external(name): + # 外のオーガナイザーのレーンは設計の承認と問いを依頼にしない(今どおり止まって人へ戻す。#2439) + raise ValueError("外のオーガナイザーのレーンは問いを依頼にしない") import durable wid = workflow_id(name) @@ -300,7 +323,7 @@ def request_ask(main: Path, name: str, lane: str, tool_input: dict, cwd: str, le "cwd": cwd, "ledger_id": ledger_id, "stamp": _head_sha(cwd), - "at": _utc_stamp(), + "at": pe.utc_stamp(), } _send_inbox(main, name, msg, rid) return rid @@ -310,7 +333,7 @@ def request_gate1(main: Path, name: str, lane: str, issue: int, pr: int, report: """設計の承認の依頼(`kind: gate1`)。id は `<レーン>-g<設計 PR>-<先頭の SHA の 12 桁>`(同じ先頭の打ち直しは同じ id)。""" rid = f"{lane}-g{pr}-{stamp[:12]}" msg = {"kind": "gate1", "id": rid, "lane": lane, "issue": issue, "pr": pr, "report": report, "sprint_state": state, "stamp": stamp} - _send_inbox(main, name, {**msg, **extra, "at": _utc_stamp()}, rid) + _send_inbox(main, name, {**msg, **extra, "at": pe.utc_stamp()}, rid) return rid @@ -326,7 +349,7 @@ def request_gate2(main: Path, name: str, n: int, k: int, report: str) -> str: rid = f"{name}-p{n}-{k}" lane = prl.helper_name(name, n) _send_inbox( - main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": _utc_stamp()}, rid + main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": pe.utc_stamp()}, rid ) return rid @@ -341,5 +364,20 @@ def send_answer( stamp: Optional[str] = None, ) -> None: """オーガナイザーの答えを送る。耐久ワークフローが `answer-<依頼>` を 1 回だけ立て、問いの一覧から外す。""" - msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": _utc_stamp()} + msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": pe.utc_stamp()} _send_inbox(main, name, msg, f"answer:{rid}") + + +def requests_for(main: Path, name: str) -> list[dict]: + """オーガナイザーが読める依頼(`releases/requests.json`。耐久ワークフローが配布を組むたびに書く。無ければ空)。""" + p = pp.plan_dir(main, name) / "releases" / "requests.json" + try: + return json.loads(p.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + + +def resume_plan(main: Path, name: str, msg: dict, key: str) -> None: + import durable + + durable.send_to(KIND, record_identity(main, name), workflow_id(name), msg, "resume", key) diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 47bca6f51..1952d1e44 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -19,6 +19,8 @@ | `answer <計画名> <依頼> (--approved \\| --revise --note <パス> \\| --rejected \\| --choice <答え>...)` | 問いの答えを送る(持ち主のレーンが止まっていれば `metrics.next` に起こす 1 行) | | `helper <計画名> <連番> <提示>` | 一時レーンが流すもの(`phase`・`pipeline`・`settle`・`gate2`)を返す | +外のオーガナイザーとの `--external` の副命令(#2439)は `lib/parallel_external_cli.py` にある。 + 置き場はメインディレクトリの `.ndf/parallel/<計画名>/`(コミットしない)。正本の手順は `skills/development-workflow/references/parallel.md` と `skills/issue-plan-strategy/references/parallel-plan.md` にある。 """ @@ -40,6 +42,9 @@ deps.require("durable", "schema", "md", "mdtable", "versions") import decl # noqa: E402 +import parallel_answer as pa # noqa: E402 +import parallel_external as pe # noqa: E402 +import parallel_external_cli as px # noqa: E402 import parallel_lane as pl # noqa: E402 import parallel_plan as pp # noqa: E402 from step_result import emit, result # noqa: E402 @@ -53,8 +58,14 @@ def main_of() -> Path: return decl.main_dir(cwd) or Path(cwd) -def reply(status: str, summary: str, items=None, code: int | None = None, **metrics): - emit(result(TOOL, status, summary, items, metrics), code) +def reply(status: str, summary: str, items=None, code: int | None = None, next_step: str | None = None, **metrics): + """結果を出して終える。gate は次の 1 手(next_step)を添える(統一 JSON レポートの形)。""" + emit(result(TOOL, status, summary, items, metrics, next=next_step), code) + + +def require_name(a) -> None: + if not a.external and not a.name: + reply("stopped", "計画名か --external が要る", code=1) def checked_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[dict], dict]: @@ -68,6 +79,7 @@ def checked_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[di def cmd_init(a) -> None: + px.refuse_in_factory_run(reply) main = main_of() try: raw = json.loads(sys.stdin.read()) @@ -132,6 +144,7 @@ def _wait_line(name: str) -> str: def cmd_run(a) -> None: + px.refuse_in_factory_run(reply) main = main_of() plan, items, metrics = checked_plan(main, a.name) if plan is None: @@ -155,6 +168,7 @@ def cmd_run(a) -> None: def cmd_start(a) -> None: + px.refuse_in_factory_run(reply) main = main_of() plan, items, metrics = checked_plan(main, a.name) if plan is None: @@ -208,14 +222,19 @@ def cmd_wait(a) -> None: def cmd_present(a) -> None: import parallel_release as pr + require_name(a) main = main_of() + if a.external: + px.present_external(main, a, reply) + if not a.approval: + reply("stopped", "--approval が要る", code=1) plan, items, metrics = checked_plan(main, a.name) if plan is None: reply("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) d = pr.release_dir(main, a.name, a.n, a.k) p1 = pr.release_dir(main, a.name, a.n, 1) / "presented.json" first = json.loads(p1.read_text(encoding="utf-8")) if a.k != 1 and p1.is_file() else None - reqs = _requests_for(main, a.name) + reqs = pl.requests_for(main, a.name) try: got = pr.present(main, plan, Path(a.approval), reqs, d, first) except ValueError as exc: @@ -231,19 +250,8 @@ def cmd_present(a) -> None: ) -def _requests_for(main: Path, name: str) -> list[dict]: - """オーガナイザーが読める依頼(`releases/requests.json`。耐久ワークフローが配布を組むたびに書く。無ければ空)。""" - p = pp.plan_dir(main, name) / "releases" / "requests.json" - try: - return json.loads(p.read_text(encoding="utf-8")) - except (OSError, ValueError): - return [] - - -def _resume_plan(main: Path, name: str, msg: dict, key: str) -> None: - import durable - - durable.send_to(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), msg, "resume", key) +def cmd_compose(a) -> None: + px.compose_external(main_of(), reply) def cmd_confirm(a) -> None: @@ -251,12 +259,15 @@ def cmd_confirm(a) -> None: if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) wid = pl.workflow_id(a.name) - _resume_plan(main, a.name, {"n": a.n, "phase": "confirm", "take": a.take}, f"{wid}:resume:confirm:{a.n}:{a.take or '-'}") + pl.resume_plan(main, a.name, {"n": a.n, "phase": "confirm", "take": a.take}, f"{wid}:resume:confirm:{a.n}:{a.take or '-'}") reply("ok", f"配布 {a.n} の承認を送った", n=a.n, take=a.take) def cmd_settle(a) -> None: + require_name(a) main = main_of() + if a.external: + px.settle_external(main, a, reply) if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) if a.rejected: @@ -272,9 +283,8 @@ def cmd_settle(a) -> None: res = "released" if results and all(r == "完了" for r in results) else "failed" version = done.get("version") or (done.get("metrics") or {}).get("version") wid = pl.workflow_id(a.name) - _resume_plan( - main, a.name, {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason}, f"{wid}:resume:settle:{a.n}" - ) + msg = {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason} + pl.resume_plan(main, a.name, msg, f"{wid}:resume:settle:{a.n}") reply("ok", f"配布 {a.n} の結果 {res} を送った", result=res, version=version) @@ -300,7 +310,10 @@ def cmd_stop(a) -> None: def cmd_request(a) -> None: + require_name(a) main = main_of() + if a.external: + px.request_external(main, a, reply, SELF) if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) if a.prs and pl.merged_on_production(main, a.prs): @@ -309,7 +322,9 @@ def cmd_request(a) -> None: reply("ok", "自分の Pull Request はすでに本番チャネルにある。依頼を送らずに続きへ進む", result="released", version=version) rid = pl.send_release_request(main, a.name, a.lane, a.prs, a.issues, Path(a.state)) pl.write_request(Path(a.state), {"id": rid, "plan": a.name, "lane": a.lane, "prs": a.prs}) - reply("gate", f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む", code=10, id=rid, result="requested") + nxt = f"python3 {SELF} await {a.name} --from {Path(a.state) / 'work' / 'request.txt'}" + summary = f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む" + reply("gate", summary, code=10, next_step=nxt, id=rid, result="requested") def cmd_await(a) -> None: @@ -320,7 +335,11 @@ def cmd_await(a) -> None: reply("stopped", f"依頼の記録を読めない: {exc}", code=2) if sent.get("result") == "released" and not sent.get("id"): reply("ok", "配布済み(依頼を送っていない)", result="released", version=sent.get("version"), deferred=None) - ans, dfr = pl.read_answer(main, a.name, sent["id"], a.timeout) + if a.external or pe.is_external(sent.get("plan")): + ans, dfr = pe.read_answer_file(px.external_dir(reply), sent["id"]), None + else: + require_name(a) + ans, dfr = pl.read_answer(main, a.name, sent["id"], a.timeout) if ans is None: reply("stopped", "答えがまだ無い", code=3, deferred=dfr) res = ans.get("result") @@ -330,6 +349,7 @@ def cmd_await(a) -> None: status, f"配布の依頼 {sent['id']} の答え: {res}", code=code, + next_step="今の承認ゲート 2 の差し戻しと同じに扱う" if code == 10 else None, result=res, version=ans.get("version"), release=ans.get("release"), @@ -364,24 +384,6 @@ def cmd_request_gate2(a) -> None: reply("ok", f"承認ゲート 2 の依頼 {rid} をオーガナイザーへ送った。答えを待つ間は止まる", id=rid) -def _owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: - """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは答えを待って止まった持ち主の - restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。同じ原因としてまとめた問い(`merged`)は、 - 代表だけでなく全持ち主をそれぞれの依頼の id で調べ、起こす行を `; ` でつなぐ(異常系 5)。""" - import durable - import parallel_run as pr_run - - items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] - q = next((q for q in items if q.get("id") == rid), {}) - if q.get("kind") in ("stall", "helper", "spawn") or not q.get("lane"): - return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) - ls = lambda: pr_run.relay(main, "ls", "--json") # noqa: E731 - owners = [(q["lane"], rid), *((m.get("lane"), m.get("id")) for m in q.get("merged") or [] if m.get("lane"))] - lanes = [lane for lane, each in owners if pl.lane_waits(main, name, lane, str(each or rid), ls)] - lanes = list(dict.fromkeys(lanes)) - return "; ".join(f"python3 {pr_run.RELAY} restart {lane}" for lane in lanes) or None - - def cmd_answer(a) -> None: main = main_of() if pl.run_status(main, a.name) not in pl.RUNNING: @@ -395,7 +397,7 @@ def cmd_answer(a) -> None: except OSError as exc: reply("stopped", f"指摘ファイルを読めない: {exc}", code=2) result = "approved" if a.approved else "revise" if a.revise else "rejected" if a.rejected else "answered" - nxt = _owner_restart(main, a.name, a.id, a.choice) + nxt = pa.owner_restart(main, a.name, a.id, a.choice) pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice) reply("ok", f"依頼 {a.id} へ答え({result})を送った", id=a.id, result=result, next=nxt) @@ -419,6 +421,8 @@ def build_parser() -> argparse.ArgumentParser: chk.add_argument("name") sta = sub.add_parser("start") sta.add_argument("name") + cp = sub.add_parser("compose") + cp.add_argument("--external", action="store_true", required=True) run_ = sub.add_parser("run") run_.add_argument("name") stp = sub.add_parser("stop") @@ -429,29 +433,34 @@ def build_parser() -> argparse.ArgumentParser: w.add_argument("--after", type=int, default=0) w.add_argument("--timeout", type=float, default=3600) p = sub.add_parser("present") - p.add_argument("name") + p.add_argument("name", nargs="?") p.add_argument("n", type=int) p.add_argument("k", type=int) - p.add_argument("--approval", required=True) + p.add_argument("--approval") + p.add_argument("--external", action="store_true") + p.add_argument("--record", action="store_true", help="--external で、承認ゲート 2 で止まった提示を presented/ へ書く") c = sub.add_parser("confirm") c.add_argument("name") c.add_argument("n", type=int) c.add_argument("--take", choices=("new", "first")) s = sub.add_parser("settle") - s.add_argument("name") + s.add_argument("name", nargs="?") s.add_argument("n", type=int) + s.add_argument("--external", action="store_true") g = s.add_mutually_exclusive_group(required=True) g.add_argument("--done") g.add_argument("--rejected", action="store_true") s.add_argument("--reason") r = sub.add_parser("request") - r.add_argument("name") + r.add_argument("name", nargs="?") + r.add_argument("--external", action="store_true") r.add_argument("--lane", required=True) r.add_argument("--prs", type=int, nargs="*", default=[]) r.add_argument("--issues", type=int, nargs="*", default=[]) r.add_argument("--state", required=True) aw = sub.add_parser("await") - aw.add_argument("name") + aw.add_argument("name", nargs="?") + aw.add_argument("--external", action="store_true") aw.add_argument("--from", required=True) aw.add_argument("--timeout", type=float, default=0) ra = sub.add_parser("request-ask") diff --git a/plugins/ndf/scripts/tests/test_parallel_external.py b/plugins/ndf/scripts/tests/test_parallel_external.py new file mode 100644 index 000000000..c21629369 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_parallel_external.py @@ -0,0 +1,294 @@ +"""外のオーガナイザーとファイルでやり取りする切り替え口(#2439。`contracts/forms/parallel-external.json`)。""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +ROOT = SCRIPTS.parent +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import parallel_external as pe # noqa: E402 +import parallel_lane as pl # noqa: E402 + +FORM = json.loads((ROOT / "contracts" / "forms" / "parallel-external.json").read_text(encoding="utf-8")) +PARALLEL = SCRIPTS / "parallel.py" + + +@pytest.mark.parametrize("case", FORM["cases"], ids=lambda c: f"{c['message']['file']}-{c['valid']}") +def test_form_cases(case): + assert (pe.file_errors(case["message"]) == []) is case["valid"] + + +@pytest.fixture +def org(tmp_path, monkeypatch): + d = tmp_path / "organizer" + monkeypatch.setenv("NDF_ORGANIZER_DIR", str(d)) + monkeypatch.setenv("NDF_ORGANIZER_LANE", "loop-l20261102") + monkeypatch.delenv("NDF_RELAY_NAME", raising=False) + return d + + +def test_lane_of_is_external_only_with_organizer_dir(tmp_path, org, monkeypatch): + got = pl.lane_of(tmp_path) + assert got is not None and got[0] == pe.EXTERNAL and got[1].name == "loop-l20261102" + monkeypatch.delenv("NDF_ORGANIZER_DIR") + assert pl.lane_of(tmp_path) is None + + +def test_external_lane_does_not_send_questions(tmp_path, org): + # 設計の承認と承認ゲートの外の問いは依頼にしない(今どおり止まって人へ戻す) + assert pl.sender_of(tmp_path, "loop-l20261102", None) is None + with pytest.raises(ValueError): + pl.request_gate1(tmp_path, pe.EXTERNAL, "loop-l20261102", 1, 2, "r", "s", "0" * 12) + + +def _run(args, cwd, env_extra=None): + import os + + env = {**os.environ, **(env_extra or {})} + got = subprocess.run([sys.executable, str(PARALLEL), *args], cwd=cwd, capture_output=True, text=True, env=env) + return got.returncode, json.loads(got.stdout.strip().splitlines()[-1]) + + +def test_request_and_await_through_files(tmp_path, org): + state = tmp_path / "plan-state" + code, out = _run( + ["request", "--external", "--lane", "loop-l20261102", "--state", str(state), "--prs", "2440", "--issues", "2439"], tmp_path + ) + assert code == 10, out + rid = out["metrics"]["id"] + body = json.loads((org / "requests" / f"{rid}.json").read_text()) + assert pe.file_errors({"file": f"requests/{rid}.json", "body": body}) == [] + # 打ち直しても同じ id + code, again = _run( + ["request", "--external", "--lane", "loop-l20261102", "--state", str(state), "--prs", "2440", "--issues", "2439"], tmp_path + ) + assert again["metrics"]["id"] == rid + sent = state / "work" / "request.txt" + code, out = _run(["await", "--external", "--from", str(sent)], tmp_path) + assert code == 3 + pe.put_json( + org / "answers" / f"{rid}.json", + {"result": "released", "version": "10.28.0", "release": "https://x/pull/1", "reason": None, "at": "t"}, + ) + code, out = _run(["await", "--external", "--from", str(sent)], tmp_path) + assert code == 0 and out["metrics"]["version"] == "10.28.0" + # ループの周回の境は計画名 --external で答えを読む + assert pl.answers_of(tmp_path, pe.EXTERNAL, [rid])[rid]["answer"]["result"] == "released" + + +@pytest.mark.parametrize("cmd", [["init", "p1"], ["run", "p1"], ["start", "p1"]]) +def test_factory_run_refuses_local_organizer(tmp_path, cmd): + code, out = _run(cmd, tmp_path, {"NDF_FACTORY_RUN": "1"}) + assert code == 1 and out["metrics"]["factory_run"] is True + + +BATCH = { + "seq": 3, + "step": "present", + "lanes": [ + {"lane": "loop-l1", "requests": [{"id": "loop-l1-r000000000001", "prs": [11]}], "issues": [1]}, + {"lane": "run-ab", "requests": [], "issues": [2]}, + ], + "presentations": [], + "choices": ["continue", "reject"], +} + + +def _builder(main, name, d, version): + return {"steps": [], "version": version, "name": name}, {"steps": [], "dir": str(d)} + + +def test_compose_present_represent_and_confirm(tmp_path): + d = tmp_path / "org" + task = pe.compose(tmp_path, d, BATCH, "SV", "PAR", lambda _m: "10.28.0", builder=_builder) + assert task["k"] == 1 and (Path(task["dir"]) / "present.json").is_file() + assert "present.json --then" in task["pipeline"] and task["settle"].startswith("PAR settle --external 3") + task2 = pe.compose(tmp_path, d, {**BATCH, "step": "represent"}, "SV", "PAR", lambda _m: "10.28.0", builder=_builder) + assert task2["k"] == 2 and Path(task2["dir"]).name == "2" + # 確定した提示 1 の本番のプランだけを流す(提示 2 の本番のプランを流さない) + task3 = pe.compose(tmp_path, d, {**BATCH, "step": "confirm:1"}, "SV", "PAR", lambda _m: "10.28.0", builder=_builder) + assert str(pe.batch_release_dir(d, 3, 1) / "release.json") in task3["pipeline"] + assert str(pe.batch_release_dir(d, 3, 2)) not in task3["pipeline"] + # 続きは前の done を値に取る `queue --resume ` だけで流す(位置引数・--done と併せない) + task4 = pe.compose( + tmp_path, d, {**BATCH, "step": "continue", "presentations": [{"k": 1}]}, "SV", "PAR", lambda _m: "", builder=_builder + ) + done4 = pe.batch_release_dir(d, 3, 1) / "release-done.json" + assert task4["pipeline"] == f"SV queue --resume {done4} && SV wait {done4}" + with pytest.raises(ValueError): + pe.compose(tmp_path, d, BATCH, "SV", "PAR", lambda _m: "", builder=_builder) + + +def test_compose_continue_uses_done_of_launched_queue(tmp_path): + # MVV 判定が通した提示のまま本番まで流れて止まったときは、記録が done.json にしか無い + d = tmp_path / "org" + rd = pe.batch_release_dir(d, 3, 1) + rd.mkdir(parents=True) + (rd / "done.json").write_text("{}") + batch = {**BATCH, "step": "continue", "presentations": [{"k": 1}]} + task = pe.compose(tmp_path, d, batch, "SV", "PAR", lambda _m: "", builder=_builder) + assert task["pipeline"] == f"SV queue --resume {rd / 'done.json'} && SV wait {rd / 'done.json'}" + # confirm が起動した後は release-done.json から続ける + (rd / "release-done.json").write_text("{}") + task = pe.compose(tmp_path, d, batch, "SV", "PAR", lambda _m: "", builder=_builder) + assert task["settle"] == f"PAR settle --external 3 --done {rd / 'release-done.json'}" + + +def _fake_run(merged: bool): + def run(args, **_kw): + if args[:3] == ["gh", "pr", "list"]: + if "--state" in args and args[args.index("--state") + 1] == "merged": + return SimpleNamespace(returncode=0, stdout="https://github.com/o/r/pull/900\n") + return SimpleNamespace(returncode=0, stdout=json.dumps([{"state": "MERGED" if merged else "OPEN", "url": "u"}])) + return SimpleNamespace(returncode=0, stdout="") + + return run + + +def _presented(d: Path, k: int = 1) -> None: + rd = pe.batch_release_dir(d, 3, k) + pe.put_json(rd / "version.json", {"version": "10.28.0"}) + pe.put_json( + rd / "presented.json", + { + "sha": "0123abcd", + "prs": [11, 12], + "lanes": {"loop-l1": {"prs": [11], "requested": True}, "run-ab": {"prs": [12], "requested": False}}, + "orphans": [], + "added": None, + }, + ) + + +def test_settle_writes_main_merged_and_auto_presented(tmp_path): + d = tmp_path / "org" + _presented(d) + pe.put_json(pe.batch_release_dir(d, 3, 1) / "answer.json", {"gates": [{"name": "関門 2", "by": "mvv", "verdict": "approve"}]}) + got = pe.settle_batch(tmp_path, d, BATCH, 3, {"items": [{"result": "完了"}]}, run=_fake_run(True)) + assert got["result"] == "released" and got["prod_pr_url"] == "https://github.com/o/r/pull/900" + assert "main_merged" in pe.read_json(d / "progress" / "3.json")["stages"] + # MVV 判定が通して承認ゲート 2 で止まらなかった提示は auto 真で書き返す + assert pe.read_json(d / "presented" / "3-1.json")["auto"] is True + for f in ("progress/3.json", "results/3.json", "presented/3-1.json"): + assert pe.file_errors({"file": f, "body": pe.read_json(d / f)}) == [] + + +def test_settle_does_not_write_auto_without_mvv_pass(tmp_path): + """presented.json の後に MVV 判定そのものが落ちた提示は、自動の承認と書き返さない(再開で人の承認を記録させない)。""" + d = tmp_path / "org" + _presented(d) + got = pe.settle_batch(tmp_path, d, BATCH, 3, {"items": [{"result": "失敗"}]}, run=_fake_run(False)) + assert got["result"] == "failed" + assert not (d / "presented" / "3-1.json").exists() + + +def test_settle_refuses_when_release_plan_stopped_at_gate(tmp_path): + d = tmp_path / "org" + _presented(d) + with pytest.raises(ValueError): + pe.settle_batch(tmp_path, d, BATCH, 3, {"items": [{"result": "関門"}]}, run=_fake_run(False)) + assert not (d / "results" / "3.json").exists() + + +def test_record_presented_on_gate_stop(tmp_path): + d = tmp_path / "org" + _presented(d) + got = pe.record_presented(d, 3, 1, auto=False) + assert got["auto"] is False and got["lanes"] == [{"lane": "loop-l1", "requested": True}, {"lane": "run-ab", "requested": False}] + + +def test_released_version_reads_ranges(tmp_path): + pe.put_json(tmp_path / "ranges" / "1.json", {"sha": "a", "version": "10.27.0", "prs": [5], "lanes": []}) + pe.put_json(tmp_path / "ranges" / "2.json", {"sha": "b", "version": "10.28.0", "prs": [11], "lanes": []}) + assert pe.range_version(tmp_path, [11]) == "10.28.0" + assert pe.range_version(tmp_path, [99]) is None + + +@pytest.mark.parametrize( + "message, expected", + [ + (None, ["{file, body} の形でない"]), + ({"file": "answers/a.json"}, ["{file, body} の形でない"]), + ({"file": 1, "body": {}}, ["{file, body} の形でない"]), + ({"file": "answers/a.json", "body": {"result": "released", "at": "t"}}, []), + ( + {"file": "answers/a.json", "body": {"result": "x", "at": 1}}, + ["result が released / rejected でない", "at が文字列でない"], + ), + ({"file": "answers/a.json", "body": {"result": "rejected"}}, ["at が文字列でない"]), + ({"file": "ranges/r.json", "body": {"prs": [1], "lanes": []}}, []), + ( + {"file": "ranges/r.json", "body": {"prs": [True], "lanes": []}}, + ["prs が整数の配列でないか lanes が配列でない"], + ), + ( + {"file": "ranges/r.json", "body": {"prs": [1], "lanes": {}}}, + ["prs が整数の配列でないか lanes が配列でない"], + ), + ({"file": "batch.json", "body": {"step": "present", "seq": 1}}, []), + ({"file": "batch.json", "body": {"step": "x", "seq": 1}}, ["step か seq の形が違う"]), + ( + { + "file": "batch.json", + "body": { + "step": "confirm:1", + "seq": 2, + "lanes": [{"lane": "a", "issues": [1], "requests": [{"prs": [3]}]}], + }, + }, + [], + ), + ( + { + "file": "batch.json", + "body": { + "step": "x", + "seq": "1", + "lanes": [ + {"lane": 1, "issues": [1]}, + {"lane": "b", "issues": [1], "requests": [{"prs": ["x"]}]}, + ], + }, + }, + ["step か seq の形が違う", "lanes の形が違う", "lanes の形が違う"], + ), + ({"file": "presented/p.json", "body": {"k": 1, "auto": False, "prs": [1]}}, []), + ( + {"file": "presented/p.json", "body": {"k": 3, "auto": False, "prs": [1]}}, + ["k・auto・prs の形が違う"], + ), + ({"file": "progress/p.json", "body": {"stages": ["tagged"]}}, []), + ({"file": "progress/p.json", "body": {"stages": ["x"]}}, ["stages に知らない段がある"]), + ({"file": "progress/p.json", "body": {}}, ["stages に知らない段がある"]), + ({"file": "results/r.json", "body": {"result": "failed", "prs": []}}, []), + ({"file": "results/r.json", "body": {"result": "rejected", "prs": []}}, ["result か prs の形が違う"]), + ({"file": "other/x.json", "body": {}}, ["知らないファイル: other/x.json"]), + ({"file": "batch2.json", "body": {}}, ["知らないファイル: batch2.json"]), + ], +) +def test_file_errors_pins_messages_per_kind(message, expected): + assert pe.file_errors(message) == expected + + +def test_file_errors_delegates_requests_to_request_errors(): + body = {"anything": 1} + assert pe.file_errors({"file": "requests/r1.json", "body": body}) == pe._request_errors("r1.json", body) + + +def test_session_ndf_version_keeps_pinned_dev_commit(monkeypatch): + """開発版の実行ジョブが渡す `@` を落とさない。plugin.json と版が違う値は使わない。""" + monkeypatch.delenv("NDF_VERSION", raising=False) + version = pe.session_ndf_version() + assert version and "@" not in version + monkeypatch.setenv("NDF_VERSION", f"{version}@0123abcd") + assert pe.session_ndf_version() == f"{version}@0123abcd" + monkeypatch.setenv("NDF_VERSION", "0.0.1@0123abcd") + assert pe.session_ndf_version() == version diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 6289c4ec2..782b7aa00 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(SCRIPTS)) import dbos # noqa: E402,F401 import durable # noqa: E402 +import parallel_answer as pa # noqa: E402 import parallel_lane as pl # noqa: E402 import parallel_plan as pp # noqa: E402 import parallel_release as prl # noqa: E402 @@ -664,8 +665,8 @@ def test_answer_to_an_own_question_returns_the_chosen_command(monkeypatch, tmp_p spec.loader.exec_module(cli) item = {"id": "h1", "kind": "helper", "lane": "p1-rel1", "commands": {"続きから起こし直す": "SPAWN"}} monkeypatch.setattr(durable, "event_from", lambda *_a, **_k: {"items": [item]}) - assert cli._owner_restart(tmp_path, "p1", "h1", ["続きから起こし直す"]) == "SPAWN" - assert cli._owner_restart(tmp_path, "p1", "h1", ["止める"]) is None + assert pa.owner_restart(tmp_path, "p1", "h1", ["続きから起こし直す"]) == "SPAWN" + assert pa.owner_restart(tmp_path, "p1", "h1", ["止める"]) is None def test_owner_is_woken_by_the_lane_side_wait_record(monkeypatch, tmp_path): @@ -696,19 +697,19 @@ def test_owner_is_woken_by_the_lane_side_wait_record(monkeypatch, tmp_path): loop.mkdir(parents=True) handoff = tmp_path / ".ndf" / "handoff" handoff.mkdir(parents=True) - assert cli._owner_restart(tmp_path, "p1", "qa") is None and cli._owner_restart(tmp_path, "p1", "qb") is None + assert pa.owner_restart(tmp_path, "p1", "qa") is None and pa.owner_restart(tmp_path, "p1", "qb") is None (loop / "loop.json").write_text(json.dumps({"status": "running"})) (handoff / "issue-7.md").write_text("## 次にやること\n- 別の作業\n") - assert cli._owner_restart(tmp_path, "p1", "qa") is None and cli._owner_restart(tmp_path, "p1", "qb") is None + assert pa.owner_restart(tmp_path, "p1", "qa") is None and pa.owner_restart(tmp_path, "p1", "qb") is None (loop / "loop.json").write_text(json.dumps({"status": "stopped", "stop": {"condition": "waiting_only"}})) (handoff / "issue-7.md").write_text("## 次にやること\n- 依頼 qb の答えを読んで続ける\n") - assert cli._owner_restart(tmp_path, "p1", "qa") == f"python3 {prun.RELAY} restart la" - assert cli._owner_restart(tmp_path, "p1", "qb") == f"python3 {prun.RELAY} restart lb" + assert pa.owner_restart(tmp_path, "p1", "qa") == f"python3 {prun.RELAY} restart la" + assert pa.owner_restart(tmp_path, "p1", "qb") == f"python3 {prun.RELAY} restart lb" items.append({"id": "qh", "kind": "ask", "lane": "p1-rel1"}) # 記録を持たない一時レーンはラッパーが止まっているかで決める - assert cli._owner_restart(tmp_path, "p1", "qh") == f"python3 {prun.RELAY} restart p1-rel1" + assert pa.owner_restart(tmp_path, "p1", "qh") == f"python3 {prun.RELAY} restart p1-rel1" # 同じ原因としてまとめた問いは、merged の持ち主もそれぞれの依頼の id で調べて起こす(異常系 5) items.append({"id": "qm", "kind": "ask", "lane": "la", "merged": [{"id": "qb", "lane": "lb"}]}) - assert cli._owner_restart(tmp_path, "p1", "qm") == f"python3 {prun.RELAY} restart la; python3 {prun.RELAY} restart lb" + assert pa.owner_restart(tmp_path, "p1", "qm") == f"python3 {prun.RELAY} restart la; python3 {prun.RELAY} restart lb" def test_a_helper_ends_by_stopping_its_own_wrapper(): diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 3bf29f131..27d3c7d5a 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -113,6 +113,40 @@ CI の失敗・衝突・版数の衝突は、配布のプランの直しの手 承認ゲート 1・承認ゲート 2・承認ゲートの外の問いは、どのレーンでもオーガナイザーへ送られる([asking.md](asking.md) の「並列の計画のレーン」)。 +## 外のオーガナイザーのレーン(`NDF_ORGANIZER_DIR`) + +環境変数 `NDF_ORGANIZER_DIR` があるセッションは、外のオーガナイザー(サービスが実行ジョブの間で動かすまとめ役など)のレーンである。 +名前は `NDF_ORGANIZER_LANE` で、依頼と答えは置き場のファイルで受け渡す(形は `contracts/forms/parallel-external.json`)。 +並列の計画(`.ndf/parallel/`)は使わず、計画名の代わりに `--external` を付ける。 + +| 点 | 決まり | +| --- | --- | +| 本番のステージ | 配布の依頼のプラン(`parallel.py request --external`)になる。依頼を `requests/.json` へ書いて 10 で終わる。自分の Pull Request がすでに `main` にあれば書かずに 0 で終わる | +| 答え | `parallel.py await --external --from <プランの状態ディレクトリ>/work/request.txt` が `answers/.json` を読む。ループのレーンは周回の境で読む(上の「レーンの側」と同じ) | +| 問い | 設計の承認と承認ゲートの外の問いは依頼にしない。今どおり止まって人へ戻す | +| 1 台の中のオーガナイザー | 起動しない。`NDF_FACTORY_RUN` がある実行ジョブでは `parallel.py init`・`start`・`run` が 1 で断る | + +### 配布の実行(`--parallel-helper --external`) + +外のオーガナイザーがまとめた配布 1 つのために起こすセッションは、次の 1 行で始める。`batch.json` の手(`step`)ごとに 1 手を打って終える。 + +```text +/goal /ndf:development-workflow --parallel-helper --external -- 終わり: 作業の報告を出したとき +``` + +1. `python3 "$NDF_SCRIPTS/parallel.py" compose --external` を打つ。`metrics.pipeline` が流すものを決める +2. `metrics.pipeline` を流す(待ちは背景で打ち、完了の通知を待つ) + +| `step` | 流すもの | 終わり方 | +| --- | --- | --- | +| `present` / `represent` | 提示 1(`represent` は提示 2)の提示のプラン → 本番のプラン | 提示のプランが承認ゲート 2 で止まったら `metrics.record` の 1 行で提示を書き返し、「判断が要る」で終える。流れ切れば手順 3 | +| `confirm:` | 答えの記録 → 提示 k のプランを `approve` から → 提示 k の本番のプラン | 手順 3。確定しない提示の本番のプランは流さない | +| `continue` | 止まった本番のプランを `--resume` で続ける | 手順 3 か、直せない失敗を問いにして止まる | + +3. `metrics.settle` の 1 行(`parallel.py settle --external <連番> --done `)を打つ。届いた段と結果を置き場へ書き、作業の報告で終える + +CI の失敗・衝突・版数の衝突は一時レーンと同じく配布のプランの直しの手順で直す。直せない失敗は「判断が要る」で止まる。 + ## 止める `python3 "$NDF_SCRIPTS/parallel.py" stop p1` で計画の全レーンへ停止を打ち、耐久ワークフローを終える。配布の途中なら、配布が終わる