From 651b9cd677741d4c37da500d3c1ee9770621a042 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 22:21:44 +0000
Subject: [PATCH 01/16] =?UTF-8?q?=E5=A5=91=E7=B4=84=E3=82=92=E6=A9=9F?=
=?UTF-8?q?=E8=83=BD=E3=81=8B=E3=82=89=E8=B5=B7=E3=81=93=E3=81=97=E3=81=9F?=
=?UTF-8?q?=E9=85=8D=E5=88=97=E3=81=AE=E5=BD=A2=E3=81=B8=E6=9B=B8=E3=81=8D?=
=?UTF-8?q?=E6=8F=9B=E3=81=88=E3=80=81=E6=93=8D=E4=BD=9C=E3=81=AE=E8=A1=A8?=
=?UTF-8?q?=E3=82=92=E7=94=9F=E6=88=90=E3=81=99=E3=82=8B=EF=BC=88#2384?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
利用者向けの 34 の操作を、取得と削除は鍵の query の繰り返し、作成・更新・操作は本文の配列にした。
各操作に操作の扱い(x-ndf-access)・要素の鍵(x-ndf-keys)・作った識別子(x-ndf-id)を持たせ、
委任の 3 操作(delegations.yaml)を足した。契約から操作の表(src/generated/operations.ts)を生成し、
形・扱い・記録へ写す鍵を検査で確かめる。
Co-Authored-By: Claude Opus 5.5
---
factory/contracts/openapi/accounts.yaml | 511 +++----
factory/contracts/openapi/delegations.yaml | 150 ++
factory/contracts/openapi/loops.yaml | 499 +++----
factory/contracts/openapi/organizer.yaml | 262 ++--
factory/contracts/openapi/runs.yaml | 1230 ++++++++---------
factory/contracts/package.json | 5 +-
factory/contracts/scripts/generate.ts | 11 +-
factory/contracts/scripts/operations.ts | 123 ++
factory/contracts/src/generated/operations.ts | 1227 ++++++++++++++++
factory/contracts/src/operations.ts | 83 ++
factory/contracts/tests/operations.test.ts | 118 ++
factory/package-lock.json | 39 +
12 files changed, 2926 insertions(+), 1332 deletions(-)
create mode 100644 factory/contracts/openapi/delegations.yaml
create mode 100644 factory/contracts/scripts/operations.ts
create mode 100644 factory/contracts/src/generated/operations.ts
create mode 100644 factory/contracts/src/operations.ts
create mode 100644 factory/contracts/tests/operations.test.ts
diff --git a/factory/contracts/openapi/accounts.yaml b/factory/contracts/openapi/accounts.yaml
index 45624838a..98ccc64d9 100644
--- a/factory/contracts/openapi/accounts.yaml
+++ b/factory/contracts/openapi/accounts.yaml
@@ -1,394 +1,303 @@
-openapi: 3.1.0
+openapi: "3.1.0"
info:
title: Neural Development Factory のアカウントの API
- version: 0.1.0
+ version: "0.1.0"
description: >-
- 画面向けのアカウント・席・実行の形態の経路(#2170)。どの経路も Cognito の access token を
- Authorization: Bearer で要る。使える量の判定は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。
- contact:
- name: devbasex/ai-plugins
- url: https://github.com/devbasex/ai-plugins
- license:
- name: MIT
- url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE
-servers:
- - url: https://api.example.com
- description: API の入口(値はインフラが FACTORY_API_URL で渡す)
-tags:
- - name: me
- description: ログインした利用者
- - name: accounts
- description: アカウント・席・実行の形態
-security:
- - cognito: []
+ 画面向けのアカウント・席・実行の形態の操作(#2170)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。使える量の判定は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。
+ contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" }
+ license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" }
+servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }]
+tags: [{ name: me, description: ログインした利用者 }, { name: accounts, description: アカウント・席・実行の形態 }]
+security: [{ cognito: [] }, { delegation: [] }]
paths:
- /me:
+ "/me":
get:
tags: [me]
operationId: getMe
summary: 自分の利用者と、席を持つアカウントの一覧
description: 初めての要求では、Free・local の個人のアカウントを 1 つ作る。
+ x-ndf-access: read
responses:
"200":
- description: 利用者とアカウントの一覧
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Me"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "503":
- $ref: "#/components/responses/Unavailable"
- /teams:
+ description: 条件で集めた値の配列
+ content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Me" } } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/teams":
post:
tags: [accounts]
operationId: createTeam
summary: チームのアカウントを作る
description: 作った利用者がチームの持ち主になり、持ち主の席が 1 つできる。
+ x-ndf-access: human
+ x-ndf-keys: [idempotency_key]
+ x-ndf-id: account_id
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/CreateTeam"
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/CreateTeamItem" } }
responses:
- "201":
- description: 作ったチームのアカウント
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/AccountDetail"
- "400":
- $ref: "#/components/responses/InvalidRequest"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "503":
- $ref: "#/components/responses/Unavailable"
- /accounts/{account_id}:
- parameters:
- - $ref: "#/components/parameters/AccountId"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts":
get:
tags: [accounts]
operationId: getAccount
summary: アカウントの料金プラン・実行の形態・使える量・席
description: 個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。
+ x-ndf-access: read
+ x-ndf-keys: [account_id]
+ parameters:
+ - name: account_id
+ in: query
+ required: true
+ style: form
+ explode: true
+ description: "要素の鍵。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
responses:
"200":
- description: アカウント
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/AccountDetail"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- /accounts/{account_id}/execution-mode:
- parameters:
- - $ref: "#/components/parameters/AccountId"
- put:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ patch:
tags: [accounts]
operationId: setExecutionMode
summary: 実行の形態を変える
description: アカウントの持ち主だけが変えられる。今と同じ値なら履歴を足さずに 200 を返す。
+ x-ndf-access: write
+ x-ndf-keys: [account_id]
requestBody:
required: true
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/SetExecutionMode"
+ type: array
+ minItems: 1
+ maxItems: 100
+ items: { $ref: "#/components/schemas/SetExecutionModeItem" }
responses:
"200":
- description: 変えた後のアカウント
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/AccountDetail"
- "400":
- $ref: "#/components/responses/InvalidRequest"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "409":
- $ref: "#/components/responses/Conflict"
- "503":
- $ref: "#/components/responses/Unavailable"
- /accounts/{account_id}/seats:
- parameters:
- - $ref: "#/components/parameters/AccountId"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/seats":
post:
tags: [accounts]
operationId: addSeat
summary: 席を足す
description: チームの持ち主が、既に 1 度ログインした利用者を ID で指して席を足す。
+ x-ndf-access: human
+ x-ndf-keys: [user_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/AddSeat"
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/AddSeat" } }
responses:
- "201":
- description: 足した席
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/Seat"
- "400":
- $ref: "#/components/responses/InvalidRequest"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "409":
- $ref: "#/components/responses/Conflict"
- "422":
- $ref: "#/components/responses/UserNotFound"
- /accounts/{account_id}/seats/{user_id}:
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/UserId"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Seat" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
patch:
tags: [accounts]
operationId: changeSeat
summary: 席の種類を変える
description: チームの持ち主だけが変えられる。
+ x-ndf-access: human
+ x-ndf-keys: [user_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/ChangeSeat"
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/ChangeSeatItem" } }
responses:
"200":
- description: 変えた後の席
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/Seat"
- "400":
- $ref: "#/components/responses/InvalidRequest"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/SeatNotFound"
- "409":
- $ref: "#/components/responses/Conflict"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Seat" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
delete:
tags: [accounts]
operationId: removeSeat
summary: 席を外す
description: チームの持ち主だけが外せる。持ち主自身の席は外せない。
+ x-ndf-access: human
+ x-ndf-keys: [user_id]
+ parameters:
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - name: user_id
+ in: query
+ required: true
+ style: form
+ explode: true
+ description: "要素の鍵。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
responses:
- "204":
- description: 外した
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/SeatNotFound"
- "409":
- $ref: "#/components/responses/Conflict"
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
+ content:
+ "application/json":
+ schema:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
components:
securitySchemes:
- cognito:
+ cognito: { type: http, scheme: bearer, bearerFormat: JWT, description: Cognito の利用者プールの access token }
+ delegation:
type: http
scheme: bearer
- bearerFormat: JWT
- description: Cognito の利用者プールの access token
- parameters:
- AccountId:
- name: account_id
- in: path
- required: true
- description: アカウントの ID
- schema:
- type: string
- UserId:
- name: user_id
- in: path
- required: true
- description: 利用者の ID(Cognito の sub)
- schema:
- type: string
+ description: >-
+ 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない
responses:
- InvalidRequest:
- description: 要求の形が正しくない(invalid_request)
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- Unauthorized:
- description: token が無い・壊れている・期限切れ・別の利用者プールかクライアントのもの(unauthorized)
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- Forbidden:
+ E400:
description: >-
- 席も持ち主でもない・アカウントが無い・持ち主の操作を持ち主でない者が求めた(forbidden)。
- アカウントの有無は出さない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- SeatNotFound:
- description: 席が無い(seat_not_found)。持ち主と確かめた後だけ返す
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- Conflict:
+ 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E401:
+ description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E403:
description: >-
- not_a_team(個人のアカウントへの席の操作)・seat_exists・owner_seat_protected・
- managed_not_allowed_on_free
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- UserNotFound:
- description: 指した利用者がまだログインしていない(user_not_found)
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- Unavailable:
- description: 保存に失敗した(account_unavailable)。時間をおいてやり直す
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E503:
+ description: 保存か操作の記録に失敗した。時間をおいてやり直す
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
schemas:
- Plan:
- type: string
- enum: [free, pro, max, team]
- SeatType:
- type: string
- enum: [standard, premium]
+ Plan: { type: string, enum: [free, pro, max, team] }
+ SeatType: { type: string, enum: [standard, premium] }
ExecutionMode:
type: string
description: local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock)
enum: [local, byok, managed]
- Idp:
- type: string
- enum: [google, github]
+ Idp: { type: string, enum: [google, github] }
AccountSummary:
type: object
required: [account_id, kind, name, plan, role, seat_type, execution_mode]
properties:
- account_id:
- type: string
- kind:
- type: string
- enum: [personal, team]
- name:
- type: [string, "null"]
- description: チームの名前。個人のアカウントは null
- plan:
- $ref: "#/components/schemas/Plan"
- role:
- type: string
- enum: [owner, member]
+ account_id: { type: string }
+ kind: { type: string, enum: [personal, team] }
+ name: { type: [string, "null"], description: チームの名前。個人のアカウントは null }
+ plan: { $ref: "#/components/schemas/Plan" }
+ role: { type: string, enum: [owner, member] }
seat_type:
description: チームのときだけ、要求した利用者の席の種類
- oneOf:
- - $ref: "#/components/schemas/SeatType"
- - type: "null"
- execution_mode:
- $ref: "#/components/schemas/ExecutionMode"
+ oneOf: [{ $ref: "#/components/schemas/SeatType" }, { type: "null" }]
+ execution_mode: { $ref: "#/components/schemas/ExecutionMode" }
Limits:
type: object
required: [concurrent_sprint_runs, monthly_tokens]
properties:
- concurrent_sprint_runs:
- type: integer
- description: 同時に動かせるスプリントの実行の数
- monthly_tokens:
- type: [integer, "null"]
- description: 月の token の上限。実行の形態が managed のときだけ数で、ほかは null(掛けない)
+ concurrent_sprint_runs: { type: integer, description: 同時に動かせるスプリントの実行の数 }
+ monthly_tokens: { type: [integer, "null"], description: 月の token の上限。実行の形態が managed のときだけ数で、ほかは null(掛けない) }
Seat:
type: object
required: [user_id, idp, seat_type, added_at]
properties:
- user_id:
- type: string
- idp:
- oneOf:
- - $ref: "#/components/schemas/Idp"
- - type: "null"
- seat_type:
- $ref: "#/components/schemas/SeatType"
- added_at:
- type: string
- format: date-time
+ user_id: { type: string }
+ idp: { oneOf: [{ $ref: "#/components/schemas/Idp" }, { type: "null" }] }
+ seat_type: { $ref: "#/components/schemas/SeatType" }
+ added_at: { type: string, format: date-time }
AccountDetail:
allOf:
- - $ref: "#/components/schemas/AccountSummary"
+ - { $ref: "#/components/schemas/AccountSummary" }
- type: object
required: [limits, seats]
properties:
- limits:
- description: 表に無い組では null
- oneOf:
- - $ref: "#/components/schemas/Limits"
- - type: "null"
+ limits: { description: 表に無い組では null, oneOf: [{ $ref: "#/components/schemas/Limits" }, { type: "null" }] }
seats:
description: チームだけ。個人のアカウントは null
- oneOf:
- - type: array
- items:
- $ref: "#/components/schemas/Seat"
- - type: "null"
+ oneOf: [{ type: array, items: { $ref: "#/components/schemas/Seat" } }, { type: "null" }]
Me:
type: object
required: [user_id, idp, personal_account_id, accounts]
properties:
- user_id:
- type: string
- idp:
- $ref: "#/components/schemas/Idp"
- personal_account_id:
- type: string
- accounts:
- type: array
- items:
- $ref: "#/components/schemas/AccountSummary"
- CreateTeam:
- type: object
- required: [name, seat_type]
- properties:
- name:
- type: string
- minLength: 1
- maxLength: 64
- seat_type:
- $ref: "#/components/schemas/SeatType"
- SetExecutionMode:
- type: object
- required: [execution_mode]
- properties:
- execution_mode:
- $ref: "#/components/schemas/ExecutionMode"
+ user_id: { type: string }
+ idp: { $ref: "#/components/schemas/Idp" }
+ personal_account_id: { type: string }
+ accounts: { type: array, items: { $ref: "#/components/schemas/AccountSummary" } }
AddSeat:
type: object
required: [user_id, seat_type]
properties:
- user_id:
- type: string
- minLength: 1
- maxLength: 128
- seat_type:
- $ref: "#/components/schemas/SeatType"
- ChangeSeat:
- type: object
- required: [seat_type]
- properties:
- seat_type:
- $ref: "#/components/schemas/SeatType"
+ user_id: { type: string, minLength: 1, maxLength: 128 }
+ seat_type: { $ref: "#/components/schemas/SeatType" }
Error:
type: object
required: [error]
@@ -397,9 +306,43 @@ components:
type: object
required: [code, message]
properties:
- code:
- type: string
- description: 機械の値
- message:
- type: string
- description: 利用者に出す文
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
+ details: { type: object }
+ ElementFailure:
+ type: object
+ description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない
+ required: [ok, status, error]
+ properties:
+ ok: { const: false }
+ status: { type: integer, enum: [400, 403, 404, 409, 422, 503] }
+ error:
+ type: object
+ required: [code, message]
+ properties:
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
+ details: { type: object }
+ CreateTeamItem:
+ type: object
+ required: [idempotency_key, name, seat_type]
+ properties:
+ idempotency_key:
+ type: string
+ pattern: "^[0-9A-Za-z-]{1,64}$"
+ description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする
+ name: { type: string, minLength: 1, maxLength: 64 }
+ seat_type: { $ref: "#/components/schemas/SeatType" }
+ SetExecutionModeItem:
+ type: object
+ required: [account_id, execution_mode]
+ properties:
+ account_id: { type: string, minLength: 1 }
+ execution_mode: { $ref: "#/components/schemas/ExecutionMode" }
+ ChangeSeatItem:
+ type: object
+ required: [user_id, seat_type]
+ properties:
+ user_id: { type: string, minLength: 1, maxLength: 128 }
+ seat_type: { $ref: "#/components/schemas/SeatType" }
+ Removed: { type: object, required: [removed], properties: { removed: { const: true } } }
diff --git a/factory/contracts/openapi/delegations.yaml b/factory/contracts/openapi/delegations.yaml
new file mode 100644
index 000000000..67c8fa696
--- /dev/null
+++ b/factory/contracts/openapi/delegations.yaml
@@ -0,0 +1,150 @@
+openapi: "3.1.0"
+info:
+ title: Neural Development Factory の委任の資格情報の API
+ version: "0.1.0"
+ description: >-
+ 人が画面で委任の資格情報を発行し、一覧で見て、取り消す操作(#2384)。委任は利用者に属し、アカウントに属さない。発行と取り消しは人だけが確定でき、委任の資格情報からは呼べない。一覧は委任の資格情報でも読める。
+ contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" }
+ license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" }
+servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }]
+tags: [{ name: delegations, description: エージェントへの委任 }]
+security: [{ cognito: [] }, { delegation: [] }]
+paths:
+ /delegations:
+ get:
+ tags: [delegations]
+ operationId: listDelegations
+ summary: 自分が発行した委任の資格情報の一覧
+ description: 新しい順に 100 件まで。値と秘密のハッシュは返さない。
+ x-ndf-access: read
+ responses:
+ "200":
+ description: 条件で集めた値の配列
+ content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Delegation" } } } }
+ "401": { $ref: "#/components/responses/E401" }
+ "503": { $ref: "#/components/responses/E503" }
+ post:
+ tags: [delegations]
+ operationId: createDelegation
+ summary: 委任の資格情報を発行する
+ description: >-
+ 値(ndfd_<資格情報の ID>_<秘密>)は発行の結果で 1 度だけ返し、平文を保存しない。同じ冪等の鍵の 2 回目は同じ資格情報を返し、credential を null にする。期限は 300〜3,600 秒。
+ x-ndf-access: human
+ x-ndf-keys: [idempotency_key]
+ x-ndf-id: delegation.credential_id
+ requestBody:
+ required: true
+ content:
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/CreateDelegationItem" } }
+ responses:
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
+ content:
+ "application/json":
+ schema:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/DelegationIssued" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ delete:
+ tags: [delegations]
+ operationId: revokeDelegation
+ summary: 委任の資格情報を取り消す
+ description: 取り消しはその後の最初の要求から効く。期限切れと取り消し済みの資格情報は何も変えずに成功を返す。他人の資格情報は forbidden の結果になる。
+ x-ndf-access: human
+ x-ndf-keys: [credential_id]
+ parameters:
+ - name: credential_id
+ in: query
+ required: true
+ style: form
+ explode: true
+ description: 要素の鍵。繰り返して 1〜100 個を指す
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
+ responses:
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
+ content:
+ "application/json":
+ schema:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Delegation" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+components:
+ securitySchemes:
+ cognito: { type: http, scheme: bearer, description: Cognito の access token }
+ delegation:
+ type: http
+ scheme: bearer
+ description: 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない
+ responses:
+ E400: { description: 要求全体の形が違う。どの要素も処理しない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }
+ E401: { description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized), content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }
+ E403: { description: 委任の資格情報で人だけの操作を呼んだ(human_only), content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }
+ E503: { description: 保存か操作の記録に失敗した。時間をおいてやり直す, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }
+ schemas:
+ Error:
+ type: object
+ required: [error]
+ properties:
+ error:
+ type: object
+ required: [code, message]
+ properties: { code: { type: string, description: 機械の値 }, message: { type: string, description: 利用者に出す文 }, details: { type: object } }
+ ElementFailure:
+ type: object
+ description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない
+ required: [ok, status, error]
+ properties:
+ ok: { const: false }
+ status: { type: integer, enum: [400, 403, 404, 409, 422, 503] }
+ error:
+ type: object
+ required: [code, message]
+ properties: { code: { type: string }, message: { type: string }, details: { type: object } }
+ DelegationScope:
+ type: string
+ description: read(読み取りだけ)・read_write(読み書き)。どちらも人だけが確定できる操作は呼べない
+ enum: [read, read_write]
+ CreateDelegationItem:
+ type: object
+ required: [client_name, scope, expires_in_seconds, idempotency_key]
+ additionalProperties: false
+ properties:
+ client_name: { type: string, minLength: 1, maxLength: 64, description: 人が付けるクライアントの名前。操作の記録には写さない }
+ scope: { $ref: "#/components/schemas/DelegationScope" }
+ expires_in_seconds: { type: integer, minimum: 300, maximum: 3600 }
+ idempotency_key: { type: string, pattern: "^[0-9A-Za-z-]{1,64}$", description: 作成の冪等の鍵 }
+ Delegation:
+ type: object
+ required: [credential_id, client_name, scope, created_at, expires_at, revoked_at, state]
+ properties:
+ credential_id: { type: string }
+ client_name: { type: string }
+ scope: { $ref: "#/components/schemas/DelegationScope" }
+ created_at: { type: string, format: date-time }
+ expires_at: { type: string, format: date-time }
+ revoked_at: { type: ["string", "null"], format: date-time }
+ state: { type: string, enum: [active, expired, revoked] }
+ DelegationIssued:
+ type: object
+ required: [delegation, credential]
+ properties:
+ delegation: { $ref: "#/components/schemas/Delegation" }
+ credential: { type: ["string", "null"], description: 委任の資格情報の値。発行の結果で 1 度だけ返す。同じ冪等の鍵の 2 回目は null }
diff --git a/factory/contracts/openapi/loops.yaml b/factory/contracts/openapi/loops.yaml
index 46b3da320..9c6e6760c 100644
--- a/factory/contracts/openapi/loops.yaml
+++ b/factory/contracts/openapi/loops.yaml
@@ -1,250 +1,247 @@
-openapi: 3.1.0
+openapi: "3.1.0"
info:
title: Neural Development Factory の開発ループの API
- version: 0.1.0
+ version: "0.1.0"
description: >-
- リポジトリの登録ごとに開発ループを始め・止め・予算を変え、周回と人の判断待ちを見る画面向けの経路(#2347)。
- どの経路も Cognito の access token を Authorization: Bearer で要る。ループの accountId が一致しなければ 403、無ければ 404 loop_not_found。
- contact:
- name: devbasex/ai-plugins
- url: https://github.com/devbasex/ai-plugins
- license:
- name: MIT
- url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE
-servers:
- - url: https://api.example.com
- description: API の入口(値はインフラが FACTORY_API_URL で渡す)
-tags:
- - name: loops
- description: 開発ループ
-security:
- - cognito: []
+ リポジトリの登録ごとに開発ループを始め・止め・予算を変え、周回と人の判断待ちを見る操作(#2347)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。ループの accountId が一致しない要素は forbidden、無い要素は loop_not_found(404)の結果になる。人の判断待ちへの答えは人だけが確定できる。
+ contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" }
+ license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" }
+servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }]
+tags: [{ name: loops, description: 開発ループ }]
+security: [{ cognito: [] }, { delegation: [] }]
paths:
- /accounts/{accountId}/repositories/{repositoryId}/loops:
+ "/accounts/{accountId}/loops":
post:
tags: [loops]
operationId: startLoop
summary: 開発ループを始める
description: >-
- マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。
- 枠で待つときも 201(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると
- 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/RepositoryId"
+ マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 枠で待つときも成功の結果(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。
+ x-ndf-access: write
+ x-ndf-keys: [idempotency_key]
+ x-ndf-id: loop_id
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/LoopStartRequest"
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopStartItem" } }
responses:
- "201":
- description: 成功
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/LoopStarted"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStarted" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
get:
tags: [loops]
operationId: listLoops
summary: リポジトリの開発ループの一覧
description: 新しい順に 50 件。
+ x-ndf-access: read
parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/RepositoryId"
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - { name: repository_id, in: query, required: true, schema: { type: integer, minimum: 1 } }
responses:
"200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LoopList"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- /accounts/{accountId}/loops/{loopId}:
- get:
- tags: [loops]
- operationId: getLoop
- summary: 開発ループの詳細
- description: 締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/LoopId"
- responses:
- "200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LoopDetail"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
+ description: 条件で集めた値の配列
+ content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/LoopRow" } } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
patch:
tags: [loops]
operationId: updateLoopBudget
summary: 開発ループの予算を変える
description: 終わりの状態でないループにだけ効き、次のジョブの指示から NDF の予算へ渡る。
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/LoopId"
+ x-ndf-access: write
+ x-ndf-keys: [loop_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/LoopBudgetUpdate"
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopBudgetItem" } }
+ responses:
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
+ content:
+ "application/json":
+ schema:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopRow" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/loops/detail":
+ get:
+ tags: [loops]
+ operationId: getLoop
+ summary: 開発ループの詳細
+ description: 締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。
+ x-ndf-access: read
+ x-ndf-keys: [loop_id]
+ parameters:
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - name: loop_id
+ in: query
+ required: true
+ style: form
+ explode: true
+ description: "要素の鍵。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/LoopRow"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
- /accounts/{accountId}/loops/{loopId}/cancel:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopDetail" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/loops/cancel":
post:
tags: [loops]
operationId: cancelLoop
summary: 開発ループを止める
description: 動いている実行があればスプリントの実行の取り消しと同じ形で止め、停止の後に cancelled へ移す。
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/LoopId"
+ x-ndf-access: write
+ x-ndf-keys: [loop_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
+ requestBody:
+ required: true
+ content:
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopRef" } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/LoopStateResult"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
- /accounts/{accountId}/loops/{loopId}/next-lap:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStateResult" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/loops/next-lap":
post:
tags: [loops]
operationId: startNextLap
summary: 次の周回を始める(形態 local)
- description: 次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/LoopId"
+ description: >-
+ 次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。
+ x-ndf-access: write
+ x-ndf-keys: [loop_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
+ requestBody:
+ required: true
+ content:
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopRef" } }
responses:
- "202":
- description: 受け付けた
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/LoopStateResult"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
- /accounts/{accountId}/loops/{loopId}/answers:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStateResult" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/loops/answers":
post:
tags: [loops]
operationId: answerLoopPending
summary: 人の判断待ちの課題に答える
- description: 答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/LoopId"
+ description: >-
+ 答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。
+ x-ndf-access: human
+ x-ndf-keys: [loop_id, issue]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/LoopAnswer"
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopAnswerItem" } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/LoopPendingList"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopPendingResult" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
components:
securitySchemes:
- cognito:
+ cognito: { type: http, scheme: bearer, description: Cognito の access token }
+ delegation:
type: http
scheme: bearer
- description: Cognito の access token
- parameters:
- AccountId:
- name: accountId
- in: path
- required: true
- schema:
- type: string
- RepositoryId:
- name: repositoryId
- in: path
- required: true
- schema:
- type: string
- LoopId:
- name: loopId
- in: path
- required: true
- schema:
- type: string
+ description: >-
+ 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない
responses:
E400:
- description: 要求の形が違う
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: >-
+ 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
E403:
- description: 権限が無い(他人のアカウントと同じ本文)
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- E404:
- description: 見つからない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- E409:
- description: 状態が合わない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: >-
+ 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E401:
+ description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E503:
+ description: 保存か操作の記録に失敗した。時間をおいてやり直す
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
schemas:
Error:
type: object
@@ -254,65 +251,50 @@ components:
type: object
required: [code, message]
properties:
- code: { type: string }
- message: { type: string }
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
details: { type: object }
- LoopName:
- type: string
- pattern: "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$"
+ LoopName: { type: string, pattern: "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" }
Budget:
type: object
additionalProperties: false
properties:
- laps: { type: ["number", "null"], exclusiveMinimum: 0 }
- usd: { type: ["number", "null"], exclusiveMinimum: 0 }
- hours: { type: ["number", "null"], exclusiveMinimum: 0 }
- LoopState:
- type: string
- enum: [running, between_laps, awaiting_approval, stopped, cancelled]
- LoopStartRequest:
- type: object
- required: [name, milestones]
- additionalProperties: false
- properties:
- name: { $ref: "#/components/schemas/LoopName" }
- milestones:
- type: array
- minItems: 1
- maxItems: 10
- uniqueItems: true
- items: { type: string, minLength: 1, maxLength: 100, pattern: "^[^,]+$" }
- budget: { $ref: "#/components/schemas/Budget" }
+ laps: { type: [number, "null"], exclusiveMinimum: 0 }
+ usd: { type: [number, "null"], exclusiveMinimum: 0 }
+ hours: { type: [number, "null"], exclusiveMinimum: 0 }
+ LoopState: { type: string, enum: [running, between_laps, awaiting_approval, stopped, cancelled] }
LoopStarted:
type: object
required: [loop_id, state]
- properties:
- loop_id: { type: string }
- state: { type: string, enum: [running, between_laps, stopped] }
+ properties: { loop_id: { type: string }, state: { type: string, enum: [running, between_laps, stopped] } }
LoopRow:
type: object
required:
- [loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail]
+ - loop_id
+ - name
+ - milestones
+ - state
+ - wait_reason
+ - laps_closed
+ - cost_usd
+ - budget
+ - budget_remaining
+ - last_run_at
+ - stop_condition
+ - stop_detail
properties:
loop_id: { type: string }
name: { $ref: "#/components/schemas/LoopName" }
milestones: { type: array, items: { type: string } }
state: { $ref: "#/components/schemas/LoopState" }
- wait_reason: { type: ["string", "null"], enum: [slots, legacy_version, user_start, null] }
+ wait_reason: { type: [string, "null"], enum: [slots, legacy_version, user_start, null] }
laps_closed: { type: integer, minimum: 0 }
cost_usd: { type: number, minimum: 0 }
budget: { $ref: "#/components/schemas/Budget" }
budget_remaining: { $ref: "#/components/schemas/Budget" }
- last_run_at: { type: ["string", "null"], format: date-time }
- stop_condition: { type: ["string", "null"] }
- stop_detail: { type: ["string", "null"] }
- LoopList:
- type: object
- required: [loops]
- properties:
- loops:
- type: array
- items: { $ref: "#/components/schemas/LoopRow" }
+ last_run_at: { type: [string, "null"], format: date-time }
+ stop_condition: { type: [string, "null"] }
+ stop_detail: { type: [string, "null"] }
LoopPending:
type: object
required: [number, reason, lap, answer]
@@ -321,11 +303,11 @@ components:
reason: { type: string }
lap: { type: integer, minimum: 1 }
answer:
- type: ["object", "null"]
+ type: [object, "null"]
required: [decision, pr]
properties:
decision: { type: string, enum: [approved, rejected] }
- pr: { type: ["integer", "null"], minimum: 1 }
+ pr: { type: [integer, "null"], minimum: 1 }
LoopDetail:
type: object
required: [loop, laps, runs, pending]
@@ -339,8 +321,8 @@ components:
properties:
lap: { type: integer, minimum: 1 }
issues: { type: array, items: { type: integer } }
- result: { type: string, enum: ["完了", "失敗", "人へ戻した"] }
- version: { type: ["string", "null"] }
+ result: { type: string, enum: [完了, 失敗, 人へ戻した] }
+ version: { type: [string, "null"] }
cost_usd: { type: number, minimum: 0 }
seconds: { type: number, minimum: 0 }
runs:
@@ -351,38 +333,73 @@ components:
properties:
sprint_run_id: { type: string }
lap: { type: integer, minimum: 1 }
- state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] }
- failure_reason: { type: ["string", "null"] }
- finish_reason: { type: ["string", "null"], enum: [section_limit, usage_limit, null] }
- resume_at: { type: ["string", "null"], format: date-time }
+ state:
+ type: string
+ enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled]
+ failure_reason: { type: [string, "null"] }
+ finish_reason: { type: [string, "null"], enum: [section_limit, usage_limit, null] }
+ resume_at: { type: [string, "null"], format: date-time }
cost_usd: { type: number, minimum: 0 }
sections: { type: integer, minimum: 0 }
- pending:
- type: array
- items: { $ref: "#/components/schemas/LoopPending" }
- LoopBudgetUpdate:
+ pending: { type: array, items: { $ref: "#/components/schemas/LoopPending" } }
+ LoopStateResult:
type: object
- required: [budget]
+ required: [state]
+ properties: { state: { $ref: "#/components/schemas/LoopState" } }
+ ElementFailure:
+ type: object
+ description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない
+ required: [ok, status, error]
+ properties:
+ ok: { const: false }
+ status: { type: integer, enum: [400, 403, 404, 409, 422, 503] }
+ error:
+ type: object
+ required: [code, message]
+ properties:
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
+ details: { type: object }
+ LoopStartItem:
+ type: object
+ required: [repository_id, idempotency_key, name, milestones]
additionalProperties: false
properties:
+ repository_id: { type: integer, minimum: 1 }
+ idempotency_key:
+ type: string
+ pattern: "^[0-9A-Za-z-]{1,64}$"
+ description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする
+ name: { $ref: "#/components/schemas/LoopName" }
+ milestones:
+ type: array
+ minItems: 1
+ maxItems: 10
+ uniqueItems: true
+ items: { type: string, minLength: 1, maxLength: 100, pattern: "^[^,]+$" }
budget: { $ref: "#/components/schemas/Budget" }
- LoopStateResult:
+ LoopBudgetItem:
type: object
- required: [state]
- properties:
- state: { $ref: "#/components/schemas/LoopState" }
- LoopAnswer:
+ required: [loop_id, budget]
+ additionalProperties: false
+ properties: { loop_id: { type: string, minLength: 1 }, budget: { $ref: "#/components/schemas/Budget" } }
+ LoopRef:
+ type: object
+ required: [loop_id]
+ additionalProperties: false
+ properties: { loop_id: { type: string, minLength: 1 } }
+ LoopAnswerItem:
type: object
- required: [issue, decision]
+ required: [loop_id, issue, decision]
additionalProperties: false
properties:
+ loop_id: { type: string, minLength: 1 }
issue: { type: integer, minimum: 1 }
decision: { type: string, enum: [approved, rejected] }
pr: { type: integer, minimum: 1 }
- LoopPendingList:
+ LoopPendingResult:
type: object
- required: [pending]
+ required: [loop_id, pending]
properties:
- pending:
- type: array
- items: { $ref: "#/components/schemas/LoopPending" }
+ loop_id: { type: string }
+ pending: { type: array, items: { $ref: "#/components/schemas/LoopPending" } }
diff --git a/factory/contracts/openapi/organizer.yaml b/factory/contracts/openapi/organizer.yaml
index 4c9eae1e7..98f0d80ff 100644
--- a/factory/contracts/openapi/organizer.yaml
+++ b/factory/contracts/openapi/organizer.yaml
@@ -1,154 +1,126 @@
-openapi: 3.1.0
+openapi: "3.1.0"
info:
title: Neural Development Factory のオーガナイザーの API
- version: 0.1.0
+ version: "0.1.0"
description: >-
- 同じリポジトリで並行する実行の承認と問いの一覧、まとめた配布の履歴、止まった配布を解く操作(#2439)。
- どの経路も Cognito の access token を Authorization: Bearer で要る。アカウントのリポジトリでなければ 403、登録が無ければ 404。
- 一覧と履歴は読むだけで、承認の依頼の答えを確定する経路はこのファイルに無い(I66)。
- contact:
- name: devbasex/ai-plugins
- url: https://github.com/devbasex/ai-plugins
- license:
- name: MIT
- url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE
-servers:
- - url: https://api.example.com
- description: API の入口(値はインフラが FACTORY_API_URL で渡す)
-tags:
- - name: organizer
- description: Factory のオーガナイザー
-security:
- - cognito: []
+ Factory のオーガナイザーの操作(#2439)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。止まった配布を解く操作は人だけが確定できる(オーガナイザーの I66)。
+ contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" }
+ license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" }
+servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }]
+tags: [{ name: organizer, description: Factory のオーガナイザー }]
+security: [{ cognito: [] }, { delegation: [] }]
paths:
- /accounts/{accountId}/repositories/{repositoryId}/approval-requests:
+ "/accounts/{accountId}/approval-requests":
get:
tags: [organizer]
operationId: listRepositoryApprovalRequests
summary: リポジトリの承認と問いの一覧
description: >-
- 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、
- 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。
+ 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。
+ x-ndf-access: read
+ x-ndf-keys: [repository_id]
parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/RepositoryId"
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - name: repository_id
+ in: query
+ required: true
+ style: form
+ explode: true
+ description: "要素の鍵。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/ApprovalRequestList"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- /accounts/{accountId}/repositories/{repositoryId}/releases:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ApprovalRequestList" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/releases":
get:
tags: [organizer]
operationId: listRepositoryReleases
summary: まとめた配布の履歴
- description: 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。
+ description: >-
+ 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。
+ x-ndf-access: read
parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/RepositoryId"
- - name: limit
- in: query
- required: false
- schema: { type: integer, minimum: 1, maximum: 100 }
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - { name: repository_id, in: query, required: true, schema: { type: integer, minimum: 1 } }
+ - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 } }
responses:
"200":
- description: 成功
+ description: 条件で集めた値の配列
content:
- application/json:
- schema:
- $ref: "#/components/schemas/ReleaseHistory"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve:
+ "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/ReleaseBatch" } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/releases/resolve":
post:
tags: [organizer]
operationId: resolveStalledRelease
summary: 止まった配布を解く
description: >-
- 配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。
- 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。
- parameters:
- - $ref: "#/components/parameters/AccountId"
- - $ref: "#/components/parameters/RepositoryId"
- - name: seq
- in: path
- required: true
- schema: { type: integer, minimum: 1 }
+ 配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。
+ x-ndf-access: human
+ x-ndf-keys: [repository_id, seq]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/ResolveRequest"
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/ResolveItem" } }
responses:
- "202":
- description: 受けた
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/ResolveResult"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ResolveResult" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
components:
securitySchemes:
- cognito:
+ cognito: { type: http, scheme: bearer, description: Cognito の access token }
+ delegation:
type: http
scheme: bearer
- description: Cognito の access token
- parameters:
- AccountId:
- name: accountId
- in: path
- required: true
- schema:
- type: string
- RepositoryId:
- name: repositoryId
- in: path
- required: true
- schema:
- type: string
+ description: >-
+ 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない
responses:
E400:
- description: 要求の形が違う
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: >-
+ 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
E403:
- description: 権限が無い(他人のアカウントと同じ本文)
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- E404:
- description: 見つからない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- E409:
- description: 状態が合わない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: >-
+ 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E401:
+ description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E503:
+ description: 保存か操作の記録に失敗した。時間をおいてやり直す
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
schemas:
Error:
type: object
@@ -158,16 +130,16 @@ components:
type: object
required: [code, message]
properties:
- code: { type: string }
- message: { type: string }
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
details: { type: object }
OrganizerSummary:
type: object
required: [current_seq, wait_reason, wait_detail]
properties:
- current_seq: { type: ["integer", "null"], description: 流れているまとめた配布の連番。null は流れていない }
- wait_reason: { type: ["string", "null"], enum: [external_release_open, legacy_run, null] }
- wait_detail: { type: ["string", "null"], description: 待ちの相手(外の Pull Request の URL・古い版の実行の数) }
+ current_seq: { type: [integer, "null"], description: 流れているまとめた配布の連番。null は流れていない }
+ wait_reason: { type: [string, "null"], enum: [external_release_open, legacy_run, null] }
+ wait_detail: { type: [string, "null"], description: 待ちの相手(外の Pull Request の URL・古い版の実行の数) }
ReleaseLane:
type: object
required: [lane, requested, prs]
@@ -180,8 +152,8 @@ components:
required: [seq, k, sha, lanes, added_prs, choices]
properties:
seq: { type: integer }
- k: { type: ["integer", "null"], description: 最後の提示の番号。提示の前に止まった配布は null }
- sha: { type: ["string", "null"] }
+ k: { type: [integer, "null"], description: 最後の提示の番号。提示の前に止まった配布は null }
+ sha: { type: [string, "null"] }
lanes: { type: array, items: { $ref: "#/components/schemas/ReleaseLane" } }
added_prs: { type: array, items: { type: integer } }
choices: { type: array, items: { type: string, enum: [continue, reject] } }
@@ -193,15 +165,16 @@ components:
sprint_run_id: { type: string }
loop_id: { type: string }
approval_request_id: { type: string }
- pr_url: { type: ["string", "null"] }
+ pr_url: { type: [string, "null"] }
number: { type: integer, description: ループの人の判断待ちの課題の番号 }
reason: { type: string }
created_at: { type: string, format: date-time }
release: { $ref: "#/components/schemas/ReleaseSummary" }
ApprovalRequestList:
type: object
- required: [organizer, items]
+ required: [repository_id, organizer, items]
properties:
+ repository_id: { type: integer }
organizer: { $ref: "#/components/schemas/OrganizerSummary" }
items: { type: array, items: { $ref: "#/components/schemas/ApprovalRequestItem" } }
Presentation:
@@ -217,25 +190,25 @@ components:
items:
type: object
required: [lane, requested]
- properties:
- lane: { type: string }
- requested: { type: boolean }
+ properties: { lane: { type: string }, requested: { type: boolean } }
added_prs: { type: array, items: { type: integer } }
at: { type: string, format: date-time }
ReleaseResult:
type: object
required: [version, prod_pr_url, sha, prs]
properties:
- version: { type: ["string", "null"] }
- prod_pr_url: { type: ["string", "null"] }
- sha: { type: ["string", "null"] }
+ version: { type: [string, "null"] }
+ prod_pr_url: { type: [string, "null"] }
+ sha: { type: [string, "null"] }
prs: { type: array, items: { type: integer } }
ReleaseLogEntry:
type: object
required: [at, kind, detail]
properties:
at: { type: string, format: date-time }
- kind: { type: string, enum: [waited, composed, presented, represented, confirmed, stalled, resolved, released, rejected] }
+ kind:
+ type: string
+ enum: [waited, composed, presented, represented, confirmed, stalled, resolved, released, rejected]
detail: { type: object }
ReleaseBatch:
type: object
@@ -245,23 +218,34 @@ components:
state: { type: string, enum: [composed, presented, represented, confirmed, stalled, released, rejected] }
release_run_id: { type: string }
presentations: { type: array, items: { $ref: "#/components/schemas/Presentation" } }
- confirmed_k: { type: ["integer", "null"] }
+ confirmed_k: { type: [integer, "null"] }
result: { oneOf: [{ $ref: "#/components/schemas/ReleaseResult" }, { type: "null" }] }
log: { type: array, items: { $ref: "#/components/schemas/ReleaseLogEntry" } }
- ReleaseHistory:
- type: object
- required: [items]
- properties:
- items: { type: array, items: { $ref: "#/components/schemas/ReleaseBatch" } }
- ResolveRequest:
- type: object
- required: [choice]
- properties:
- choice: { type: string, enum: [continue, reject] }
- body: { type: string, maxLength: 4000 }
ResolveResult:
type: object
required: [seq, state]
properties:
seq: { type: integer }
state: { type: string, enum: [composed, represented, confirmed, rejected] }
+ ElementFailure:
+ type: object
+ description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない
+ required: [ok, status, error]
+ properties:
+ ok: { const: false }
+ status: { type: integer, enum: [400, 403, 404, 409, 422, 503] }
+ error:
+ type: object
+ required: [code, message]
+ properties:
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
+ details: { type: object }
+ ResolveItem:
+ type: object
+ required: [repository_id, seq, choice]
+ properties:
+ repository_id: { type: integer, minimum: 1 }
+ seq: { type: integer, minimum: 1 }
+ choice: { type: string, enum: [continue, reject] }
+ body: { type: string, maxLength: 4000 }
diff --git a/factory/contracts/openapi/runs.yaml b/factory/contracts/openapi/runs.yaml
index 9aa7ff265..528694657 100644
--- a/factory/contracts/openapi/runs.yaml
+++ b/factory/contracts/openapi/runs.yaml
@@ -1,686 +1,622 @@
-openapi: 3.1.0
+openapi: "3.1.0"
info:
title: Neural Development Factory のスプリントの実行の API
- version: 0.1.0
+ version: "0.1.0"
description: >-
- 画面向けの取り込み・キーの持ち込み・課題の候補・スプリントの実行の経路と、ランナー向けの経路(#2186)。
- 画面向けは Cognito の access token、ランナー向け(/run-jobs/*)は実行ジョブの token を Authorization: Bearer で要る。
- 再開の要求と形態 local の渡す口は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。
- contact:
- name: devbasex/ai-plugins
- url: https://github.com/devbasex/ai-plugins
- license:
- name: MIT
- url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE
+ 画面向けの取り込み・キーの持ち込み・課題の候補・スプリントの実行の操作と、ランナー向けの経路(#2186)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。ランナー向け(/run-jobs/*)は実行ジョブの token を要り、配列の規則の外にある。再開の要求と形態 local の渡す口は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。
+ contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" }
+ license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" }
servers:
- - url: https://api.example.com
- description: API の入口(値はインフラが FACTORY_API_URL で渡す。/run-jobs/* は INGEST_URL と同じ origin)
+ - url: "https://api.example.com"
+ description: >-
+ API の入口(値はインフラが FACTORY_API_URL で渡す。/run-jobs/* は INGEST_URL と同じ origin)
tags:
- - name: repositories
- description: リポジトリの登録
- - name: credentials
- description: キーの持ち込み
- - name: sprint-runs
- description: 課題の候補とスプリントの実行
- - name: run-jobs
- description: ランナー向けの経路
-security:
- - cognito: []
+ - { name: repositories, description: リポジトリの登録 }
+ - { name: credentials, description: キーの持ち込み }
+ - { name: sprint-runs, description: 課題の候補とスプリントの実行 }
+ - { name: run-jobs, description: ランナー向けの経路 }
+security: [{ cognito: [] }, { delegation: [] }]
paths:
- /accounts/{accountId}/github/install-url:
+ "/run-jobs/github-token":
+ post:
+ tags: [run-jobs]
+ operationId: postRunJobGitHubToken
+ summary: 導入の token の渡し直し
+ description: >-
+ 実行ジョブの token で認証し、その実行のリポジトリ 1 つに絞った 1 時間の導入の token を返す(決定 7)。
+ security: [{ runJob: [] }]
+ responses:
+ "200":
+ description: 成功
+ content: { "application/json": { schema: { $ref: "#/components/schemas/GitHubToken" } } }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "409": { $ref: "#/components/responses/E409" }
+ "/run-jobs/state":
+ post:
+ tags: [run-jobs]
+ operationId: postRunJobState
+ summary: 状態の置き場の署名つき URL
+ description: その実行の鍵だけの 15 分の URL を返す(決定 8)。get で保存が無ければ 404。
+ security: [{ runJob: [] }]
+ requestBody:
+ required: true
+ content: { "application/json": { schema: { $ref: "#/components/schemas/StateRequest" } } }
+ responses:
+ "200":
+ description: 成功
+ content: { "application/json": { schema: { $ref: "#/components/schemas/SignedUrl" } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "404": { $ref: "#/components/responses/E404" }
+ "/run-jobs/approval-requests/{approvalRequestId}/answer":
+ get:
+ tags: [run-jobs]
+ operationId: getRunJobApprovalAnswer
+ summary: 承認の依頼への人の答え
+ description: >-
+ resume の実行ジョブが、起動の指示の APPROVAL_REQUEST_ID で人の答えを 1 回だけ読む。実行ジョブの token で認証し、 その実行の依頼でなければ 403、答えがまだ無ければ 409。sprint.json の置き場は返さない (ランナーが復元した状態から求める)。答えを保存する側の実装は #2173。
+ security: [{ runJob: [] }]
+ parameters: [{ name: approvalRequestId, in: path, required: true, schema: { type: string } }]
+ responses:
+ "200":
+ description: 成功
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ApprovalAnswer" } } }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "404": { $ref: "#/components/responses/E404" }
+ "409": { $ref: "#/components/responses/E409" }
+ "/run-jobs/organizer/answers":
+ get:
+ tags: [run-jobs]
+ operationId: getRunJobOrganizerAnswers
+ summary: 配布の依頼の答えと配布済みの範囲
+ description: >-
+ 実行ジョブの token で認証し、そのジョブの実行のレーン(ループならループのすべての実行)の配布の依頼の答えと、 リポジトリの配布済みの範囲を返す(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へそのまま書く。
+ security: [{ runJob: [] }]
+ responses:
+ "200":
+ description: 成功
+ content: { "application/json": { schema: { $ref: "#/components/schemas/OrganizerAnswers" } } }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "/run-jobs/organizer/batch":
+ get:
+ tags: [run-jobs]
+ operationId: getRunJobOrganizerBatch
+ summary: 配布の実行のまとめた配布の中身
+ description: >-
+ 配布の実行のジョブの token だけが読める(ほかの実行は 403)。レーンの一覧と依頼、提示、選択肢と、 このジョブの手(step)を返す。ランナーは NDF の置き場の batch.json へそのまま書く(#2439)。
+ security: [{ runJob: [] }]
+ responses:
+ "200":
+ description: 成功
+ content: { "application/json": { schema: { $ref: "#/components/schemas/OrganizerBatch" } } }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "404": { $ref: "#/components/responses/E404" }
+ "/accounts/{accountId}/github/install-url":
get:
tags: [repositories]
operationId: getInstallUrl
summary: GitHub App の導入の画面の URL
- description: 利用者が自分のリポジトリへ GitHub App を入れる画面の URL を返す。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ description: GitHub App の導入の URL。要素 1 個の配列で返す。
+ x-ndf-access: read
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
responses:
"200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Url"
- "403":
- $ref: "#/components/responses/E403"
- /accounts/{accountId}/github/authorize-url:
+ description: 条件で集めた値の配列
+ content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Url" } } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/github/authorize-url":
get:
tags: [repositories]
operationId: getAuthorizeUrl
summary: GitHub App の利用者の認可の URL
description: state は利用者とアカウントを署名した 10 分の値。持ち主だけが使える。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ x-ndf-access: read
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
responses:
"200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Url"
- "403":
- $ref: "#/components/responses/E403"
- /accounts/{accountId}/github/candidates:
+ description: 条件で集めた値の配列
+ content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Url" } } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/github/candidates":
post:
tags: [repositories]
operationId: postRepositoryCandidates
summary: 認可の戻りから登録の候補を返す
description: 導入の対象で利用者の届くリポジトリと、10 分の候補の証明を返す。利用者の token は保存しない。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ x-ndf-access: write
+ x-ndf-keys: []
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/CandidatesRequest"
+ type: array
+ minItems: 1
+ maxItems: 100
+ items: { $ref: "#/components/schemas/CandidatesRequest" }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/RepositoryCandidates"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- /accounts/{accountId}/repositories:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/RepositoryCandidates" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/repositories":
get:
tags: [repositories]
operationId: listRepositories
summary: 登録したリポジトリの一覧
description: リポジトリごとの終わりの状態でないスプリントの実行の数を添える。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ x-ndf-access: read
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
responses:
"200":
- description: 成功
+ description: 条件で集めた値の配列
content:
- application/json:
- schema:
- $ref: "#/components/schemas/RepositoryList"
- "403":
- $ref: "#/components/responses/E403"
+ "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Repository" } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
post:
tags: [repositories]
operationId: registerRepository
summary: リポジトリを登録する
description: 候補の証明と、登録の直前の導入の token の作成で確かめる(I33)。同じリポジトリは 409(I32)。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ x-ndf-access: write
+ x-ndf-keys: [repository_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
+ requestBody:
+ required: true
+ content:
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/RegisterRequest" } }
+ responses:
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
+ content:
+ "application/json":
+ schema:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Repository" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ patch:
+ tags: [repositories]
+ operationId: updateRepository
+ summary: リポジトリの同時の実行の数を宣言する
+ description: "料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。"
+ x-ndf-access: write
+ x-ndf-keys: [repository_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/RegisterRequest"
+ type: array
+ minItems: 1
+ maxItems: 100
+ items: { $ref: "#/components/schemas/RepositoryUpdateItem" }
responses:
- "201":
- description: 成功
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/Repository"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "409":
- $ref: "#/components/responses/E409"
- /accounts/{accountId}/model-credential:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Repository" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ delete:
+ tags: [repositories]
+ operationId: removeRepository
+ summary: リポジトリの登録を外す
+ description: 終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。
+ x-ndf-access: write
+ x-ndf-keys: [repository_id]
+ parameters:
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - name: repository_id
+ in: query
+ required: true
+ style: form
+ explode: true
+ description: "要素の鍵。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } }
+ responses:
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
+ content:
+ "application/json":
+ schema:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/model-credential":
get:
tags: [credentials]
operationId: getModelCredential
summary: 持ち込んだキーの有無
description: 登録済みか・種類・更新の時刻・外部 ID だけを返し、値も置き場の ARN も返さない(I38)。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ x-ndf-access: read
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
responses:
"200":
- description: 成功
+ description: 条件で集めた値の配列
content:
- application/json:
- schema:
- $ref: "#/components/schemas/ModelCredential"
- "403":
- $ref: "#/components/responses/E403"
+ "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/ModelCredential" } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
put:
tags: [credentials]
operationId: putModelCredential
summary: キーを保存・差し替える
description: Anthropic API キーか AWS のロールの ARN と地域のどちらか 1 つ。持ち主だけが使える。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ x-ndf-access: human
+ x-ndf-keys: []
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/ModelCredentialInput"
+ type: array
+ minItems: 1
+ maxItems: 100
+ items: { $ref: "#/components/schemas/ModelCredentialInput" }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/ModelCredential"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "503":
- $ref: "#/components/responses/E503"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ModelCredential" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
delete:
tags: [credentials]
operationId: deleteModelCredential
summary: 持ち込んだキーを消す
- description: 復元の期間を置かずに消す(決定 14)。無くても 204。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ description: 復元の期間を置かずに消す(決定 14)。無くても成功の結果を返す。
+ x-ndf-access: human
+ x-ndf-keys: []
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
responses:
- "204":
- description: 成功
- "403":
- $ref: "#/components/responses/E403"
- "503":
- $ref: "#/components/responses/E503"
- /accounts/{accountId}/repositories/{repositoryId}/issue-candidates:
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
+ content:
+ "application/json":
+ schema:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/issue-candidates":
post:
tags: [sprint-runs]
operationId: postIssueCandidates
summary: AI が選んだ課題の候補
- description: 棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
- - name: repositoryId
- in: path
- required: true
- schema:
- type: string
+ description: >-
+ 棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。言語モデルを呼び費用が掛かるため、扱いは書き込み。
+ x-ndf-access: write
+ x-ndf-keys: [repository_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/IssueCandidatesRequest"
+ type: array
+ minItems: 1
+ maxItems: 100
+ items: { $ref: "#/components/schemas/IssueCandidatesItem" }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/IssueCandidates"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- /accounts/{accountId}/sprint-runs:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/IssueCandidates" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/sprint-runs":
post:
tags: [sprint-runs]
operationId: startSprintRun
summary: スプリントの実行を始める
description: >-
- 確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。
- 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、
- 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、
- 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ 確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。
+ x-ndf-access: write
+ x-ndf-keys: [idempotency_key]
+ x-ndf-id: sprint_run_id
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/StartRequest"
+ type: array
+ minItems: 1
+ maxItems: 100
+ items: { $ref: "#/components/schemas/StartSprintRunItem" }
responses:
- "201":
- description: 成功
+ "200":
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/SprintRunStarted"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/SprintRunStarted" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
get:
tags: [sprint-runs]
operationId: listSprintRuns
summary: スプリントの実行の一覧
- description: 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。
+ description: >-
+ 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。
+ x-ndf-access: read
parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
- name: repository_id
in: query
required: false
- schema:
- type: integer
- minimum: 1
+ description: 省くとアカウントの新しい順の 50 件
+ schema: { type: integer, minimum: 1 }
responses:
"200":
- description: 成功
+ description: 条件で集めた値の配列
content:
- application/json:
- schema:
- $ref: "#/components/schemas/SprintRunList"
- "403":
- $ref: "#/components/responses/E403"
- "503":
- $ref: "#/components/responses/E503"
- /accounts/{accountId}/sprint-runs/{sprintRunId}:
+ "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/SprintRunRow" } } }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/sprint-runs/detail":
get:
tags: [sprint-runs]
operationId: getSprintRunDetail
summary: スプリントの詳細
description: >-
- フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。
- 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の経路で 1 件ずつ読む。
- step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。
- 無い実行・アカウントに属さない実行も、他人のアカウントと同じ本文の 403。
+ フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の操作(getStepDetail)で読む。 step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 無い実行・アカウントに属さない実行の要素は、他人のアカウントと同じ本文の forbidden の結果になる。
+ x-ndf-access: read
+ x-ndf-keys: [sprint_run_id]
parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
- - name: sprintRunId
- in: path
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - name: sprint_run_id
+ in: query
required: true
- schema:
- type: string
+ style: form
+ explode: true
+ description: "要素の鍵。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/SprintRunDetail"
- "403":
- $ref: "#/components/responses/E403"
- "503":
- $ref: "#/components/responses/E503"
- /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/SprintRunDetail" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/sprint-runs/steps":
get:
tags: [sprint-runs]
operationId: getStepDetail
summary: 工程の中身
description: >-
- 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。
- stepKey は事象の idempotency_key(URL の符号化をする)。記録の無い項目は null。
- 無い工程・別の実行の工程も、他人のアカウントと同じ本文の 403。
+ 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 step_key は事象の idempotency_key。記録の無い項目は null。 無い工程・別の実行の工程の要素も、他人のアカウントと同じ本文の forbidden の結果になる。
+ x-ndf-access: read
+ x-ndf-keys: [step_key]
parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
- - name: sprintRunId
- in: path
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - name: sprint_run_id
+ in: query
required: true
- schema:
- type: string
- - name: stepKey
- in: path
+ description: 工程を持つ実行。他人の実行なら要求全体が 403
+ schema: { type: string }
+ - name: step_key
+ in: query
required: true
- schema:
- type: string
+ style: form
+ explode: true
+ description: "工程の鍵(事象の idempotency_key)。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/StepDetail"
- "403":
- $ref: "#/components/responses/E403"
- "503":
- $ref: "#/components/responses/E503"
- /accounts/{accountId}/repositories/{repositoryId}/backlog:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/StepDetail" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/backlogs":
get:
tags: [repositories]
operationId: getBacklog
summary: バックログ
description: >-
- リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。
- 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリも、他人のアカウントと同じ本文の 403。
+ リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリの要素も、他人のアカウントと同じ本文の forbidden の結果になる。
+ x-ndf-access: read
+ x-ndf-keys: [repository_id]
parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
- - name: repositoryId
- in: path
+ - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }
+ - name: repository_id
+ in: query
required: true
- schema:
- type: integer
- minimum: 1
+ style: form
+ explode: true
+ description: "要素の鍵。繰り返して 1〜100 個を指す"
+ schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/Backlog"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "503":
- $ref: "#/components/responses/E503"
- /accounts/{accountId}/sprint-runs/{sprintRunId}/cancel:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Backlog" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
+ "/accounts/{accountId}/sprint-runs/cancel":
post:
tags: [sprint-runs]
operationId: cancelSprintRun
summary: スプリントの実行を取り消す
- description: 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
- - name: sprintRunId
- in: path
- required: true
- schema:
- type: string
- responses:
- "200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/CancelResult"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
- /run-jobs/github-token:
- post:
- tags: [run-jobs]
- operationId: postRunJobGitHubToken
- summary: 導入の token の渡し直し
- description: 実行ジョブの token で認証し、その実行のリポジトリ 1 つに絞った 1 時間の導入の token を返す(決定 7)。
- security:
- - runJob: []
- responses:
- "200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/GitHubToken"
- "401":
- $ref: "#/components/responses/E401"
- "403":
- $ref: "#/components/responses/E403"
- "409":
- $ref: "#/components/responses/E409"
- /run-jobs/state:
- post:
- tags: [run-jobs]
- operationId: postRunJobState
- summary: 状態の置き場の署名つき URL
- description: その実行の鍵だけの 15 分の URL を返す(決定 8)。get で保存が無ければ 404。
- security:
- - runJob: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/StateRequest"
- responses:
- "200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/SignedUrl"
- "400":
- $ref: "#/components/responses/E400"
- "401":
- $ref: "#/components/responses/E401"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- /run-jobs/approval-requests/{approvalRequestId}/answer:
- get:
- tags: [run-jobs]
- operationId: getRunJobApprovalAnswer
- summary: 承認の依頼への人の答え
description: >-
- resume の実行ジョブが、起動の指示の APPROVAL_REQUEST_ID で人の答えを 1 回だけ読む。実行ジョブの token で認証し、
- その実行の依頼でなければ 403、答えがまだ無ければ 409。sprint.json の置き場は返さない
- (ランナーが復元した状態から求める)。答えを保存する側の実装は #2173。
- security:
- - runJob: []
- parameters:
- - name: approvalRequestId
- in: path
- required: true
- schema: { type: string }
- responses:
- "200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ApprovalAnswer"
- "401":
- $ref: "#/components/responses/E401"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
- /accounts/{accountId}/repositories/{repositoryId}:
- patch:
- tags: [repositories]
- operationId: updateRepository
- summary: リポジトリの同時の実行の数を宣言する
- description: 料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
- - name: repositoryId
- in: path
- required: true
- schema:
- type: string
+ 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。
+ x-ndf-access: write
+ x-ndf-keys: [sprint_run_id]
+ parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }]
requestBody:
required: true
content:
- application/json:
- schema:
- $ref: "#/components/schemas/RepositoryUpdate"
- responses:
- "200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Repository"
- "400":
- $ref: "#/components/responses/E400"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- delete:
- tags: [repositories]
- operationId: removeRepository
- summary: リポジトリの登録を外す
- description: 終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。
- parameters:
- - name: accountId
- in: path
- required: true
- schema:
- type: string
- - name: repositoryId
- in: path
- required: true
- schema:
- type: string
- responses:
- "204":
- description: 成功
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
- "409":
- $ref: "#/components/responses/E409"
- /run-jobs/organizer/answers:
- get:
- tags: [run-jobs]
- operationId: getRunJobOrganizerAnswers
- summary: 配布の依頼の答えと配布済みの範囲
- description: >-
- 実行ジョブの token で認証し、そのジョブの実行のレーン(ループならループのすべての実行)の配布の依頼の答えと、
- リポジトリの配布済みの範囲を返す(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へそのまま書く。
- security:
- - runJob: []
- responses:
- "200":
- description: 成功
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/OrganizerAnswers"
- "401":
- $ref: "#/components/responses/E401"
- "403":
- $ref: "#/components/responses/E403"
- /run-jobs/organizer/batch:
- get:
- tags: [run-jobs]
- operationId: getRunJobOrganizerBatch
- summary: 配布の実行のまとめた配布の中身
- description: >-
- 配布の実行のジョブの token だけが読める(ほかの実行は 403)。レーンの一覧と依頼、提示、選択肢と、
- このジョブの手(step)を返す。ランナーは NDF の置き場の batch.json へそのまま書く(#2439)。
- security:
- - runJob: []
+ "application/json":
+ schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/SprintRunRef" } }
responses:
"200":
- description: 成功
+ description: 入力と同じ順・同じ数の要素の結果
content:
- application/json:
+ "application/json":
schema:
- $ref: "#/components/schemas/OrganizerBatch"
- "401":
- $ref: "#/components/responses/E401"
- "403":
- $ref: "#/components/responses/E403"
- "404":
- $ref: "#/components/responses/E404"
+ type: array
+ items:
+ oneOf:
+ - type: object
+ required: [ok, value]
+ properties: { ok: { const: true }, value: { $ref: "#/components/schemas/CancelResult" } }
+ - { $ref: "#/components/schemas/ElementFailure" }
+ "400": { $ref: "#/components/responses/E400" }
+ "401": { $ref: "#/components/responses/E401" }
+ "403": { $ref: "#/components/responses/E403" }
+ "503": { $ref: "#/components/responses/E503" }
components:
securitySchemes:
- cognito:
- type: http
- scheme: bearer
- description: Cognito の access token
- runJob:
+ cognito: { type: http, scheme: bearer, description: Cognito の access token }
+ runJob: { type: http, scheme: bearer, description: 実行ジョブの token }
+ delegation:
type: http
scheme: bearer
- description: 実行ジョブの token
+ description: >-
+ 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない
responses:
E400:
- description: 要求の形が違う
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: >-
+ 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
E401:
- description: 認証できない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
E403:
- description: 権限が無い(他人のアカウントと同じ本文)
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- E404:
- description: 見つからない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
- E409:
- description: 状態が合わない
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: >-
+ 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient)
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
+ E404: { description: 見つからない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }
+ E409: { description: 状態が合わない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }
E503:
- description: 時間をおいてやり直す
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Error"
+ description: 保存か操作の記録に失敗した。時間をおいてやり直す
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
schemas:
Error:
type: object
@@ -690,20 +626,14 @@ components:
type: object
required: [code, message]
properties:
- code: { type: string }
- message: { type: string }
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
details: { type: object }
- Url:
- type: object
- required: [url]
- properties:
- url: { type: string, format: uri }
+ Url: { type: object, required: [url], properties: { url: { type: string, format: uri } } }
CandidatesRequest:
type: object
required: [code, state]
- properties:
- code: { type: string, minLength: 1 }
- state: { type: string, minLength: 1 }
+ properties: { code: { type: string, minLength: 1 }, state: { type: string, minLength: 1 } }
RepositoryCandidates:
type: object
required: [proof, candidates]
@@ -722,9 +652,7 @@ components:
RegisterRequest:
type: object
required: [proof, repository_id]
- properties:
- proof: { type: string }
- repository_id: { type: integer, minimum: 1 }
+ properties: { proof: { type: string }, repository_id: { type: integer, minimum: 1 } }
Repository:
type: object
required: [repository_id, full_name, installation_id, registered_at]
@@ -734,36 +662,21 @@ components:
installation_id: { type: integer }
registered_at: { type: string, format: date-time }
active_sprint_runs: { type: integer, minimum: 0 }
- max_concurrent_runs: { type: ["integer", "null"], minimum: 1, maximum: 100 }
- RepositoryUpdate:
- type: object
- required: [max_concurrent_runs]
- additionalProperties: false
- properties:
- max_concurrent_runs: { type: ["integer", "null"], minimum: 1, maximum: 100 }
- RepositoryList:
- type: object
- required: [repositories]
- properties:
- repositories:
- type: array
- items: { $ref: "#/components/schemas/Repository" }
+ max_concurrent_runs: { type: [integer, "null"], minimum: 1, maximum: 100 }
ModelCredential:
type: object
required: [registered, kind, updated_at, external_id]
properties:
registered: { type: boolean }
- kind: { type: ["string", "null"], enum: [anthropic_api_key, aws_role, null] }
- updated_at: { type: ["string", "null"], format: date-time }
+ kind: { type: [string, "null"], enum: [anthropic_api_key, aws_role, null] }
+ updated_at: { type: [string, "null"], format: date-time }
external_id: { type: string }
ModelCredentialInput:
oneOf:
- type: object
required: [kind, api_key]
additionalProperties: false
- properties:
- kind: { const: anthropic_api_key }
- api_key: { type: string, minLength: 1, maxLength: 512 }
+ properties: { kind: { const: anthropic_api_key }, api_key: { type: string, minLength: 1, maxLength: 512 } }
- type: object
required: [kind, role_arn, region]
additionalProperties: false
@@ -771,19 +684,13 @@ components:
kind: { const: aws_role }
role_arn: { type: string, maxLength: 2048, pattern: "^arn:aws[a-z-]*:iam::\\d{12}:role/.+$" }
region: { type: string, maxLength: 32, pattern: "^[a-z]{2}(-[a-z]+)+-\\d$" }
- IssueCandidatesRequest:
- type: object
- properties:
- exclude:
- type: array
- items: { type: integer, minimum: 1 }
IssueCandidates:
type: object
required: [source, milestone, candidates, note]
properties:
source: { type: string, enum: [ranking, recent] }
- milestone: { type: ["string", "null"] }
- note: { type: ["string", "null"] }
+ milestone: { type: [string, "null"] }
+ note: { type: [string, "null"] }
candidates:
type: array
items:
@@ -794,70 +701,29 @@ components:
title: { type: string }
selected: { type: boolean }
reason: { type: string, minLength: 1 }
- StartRequest:
- type: object
- required: [repository_id, issues]
- properties:
- repository_id: { type: integer, minimum: 1 }
- issues:
- type: array
- minItems: 1
- maxItems: 20
- uniqueItems: true
- items: { type: integer, minimum: 1 }
SprintRunStarted:
type: object
required: [sprint_run_id, state]
- properties:
- sprint_run_id: { type: string }
- state: { const: starting }
- SprintRunList:
- type: object
- required: [sprint_runs]
- properties:
- sprint_runs:
- type: array
- items:
- type: object
- required: [sprint_run_id, repository_id, issues, state, failure_reason, created_at]
- properties:
- sprint_run_id: { type: string }
- repository_id: { type: integer }
- issues: { type: array, items: { type: integer } }
- state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] }
- failure_reason: { type: ["string", "null"] }
- created_at: { type: string, format: date-time }
- repo: { type: string, description: owner/name }
- current_phase:
- type: ["string", "null"]
- description: 最後の phase.changed のフェーズの名前。null は始まる前。5 つの名前に無い値もそのまま返す
- current_step: { type: ["string", "null"], description: 最後の step.recorded のステップ }
- last_event_at: { type: ["string", "null"], format: date-time, description: 最後の事象の時刻 }
+ properties: { sprint_run_id: { type: string }, state: { const: starting } }
PullRequestLink:
type: object
required: [repo, number, url]
- properties:
- repo: { type: string }
- number: { type: integer }
- url: { type: string, format: uri }
+ properties: { repo: { type: string }, number: { type: integer }, url: { type: string, format: uri } }
StepRow:
type: object
required: [key, at, job_id, step, phase, issues, exit_code, seconds, cost_usd, summary, pull_request, has_details]
properties:
key: { type: string, description: 事象の idempotency_key }
at: { type: string, format: date-time }
- job_id: { type: ["string", "null"] }
+ job_id: { type: [string, "null"] }
step: { type: string }
- phase: { type: ["string", "null"], description: 工程のフェーズ。無ければ同じ実行ジョブの直前の phase.changed から導く }
+ phase: { type: [string, "null"], description: 工程のフェーズ。無ければ同じ実行ジョブの直前の phase.changed から導く }
issues: { type: array, items: { type: integer }, description: 空はスプリント全体の工程 }
exit_code: { type: integer }
seconds: { type: number }
- cost_usd: { type: ["number", "null"], description: 同じ実行ジョブ・ステップ・時刻の usage.recorded の和。結べなければ null }
+ cost_usd: { type: [number, "null"], description: 同じ実行ジョブ・ステップ・時刻の usage.recorded の和。結べなければ null }
summary: { type: string }
- pull_request:
- oneOf:
- - $ref: "#/components/schemas/PullRequestLink"
- - type: "null"
+ pull_request: { oneOf: [{ $ref: "#/components/schemas/PullRequestLink" }, { type: "null" }] }
has_details: { type: boolean, description: 工程の中身の本文を持つか }
RequiredStep:
type: object
@@ -869,17 +735,14 @@ components:
oneOf:
- type: object
required: [key, exit_code, seconds]
- properties:
- key: { type: string }
- exit_code: { type: integer }
- seconds: { type: number }
- - type: "null"
+ properties: { key: { type: string }, exit_code: { type: integer }, seconds: { type: number } }
+ - { type: "null" }
GateView:
type: object
required: [state, at]
properties:
state: { type: string, enum: [not_reached, waiting, approved, rejected] }
- at: { type: ["string", "null"], format: date-time }
+ at: { type: [string, "null"], format: date-time }
SprintRunDetail:
type: object
required:
@@ -905,17 +768,20 @@ components:
sprint_run_id: { type: string }
repository_id: { type: integer }
issues: { type: array, items: { type: integer } }
- state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] }
- failure_reason: { type: ["string", "null"] }
+ state:
+ type: string
+ enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled]
+ failure_reason: { type: [string, "null"] }
created_at: { type: string, format: date-time }
repo: { type: string }
ndf_version: { type: string }
execution_mode: { type: string, enum: [local, byok, managed] }
job_count: { type: integer }
- current_phase: { type: ["string", "null"] }
+ current_phase: { type: [string, "null"] }
phases:
type: array
- description: 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ
+ description: >-
+ 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ
items:
type: object
required: [name, step_keys, required]
@@ -930,7 +796,7 @@ components:
type: object
required: [issue, step_keys, pull_requests]
properties:
- issue: { type: ["integer", "null"] }
+ issue: { type: [integer, "null"] }
step_keys: { type: array, items: { type: string } }
pull_requests:
type: array
@@ -949,7 +815,7 @@ components:
required: [name, conclusion]
properties:
name: { type: string }
- conclusion: { type: ["string", "null"], description: null は実行中 }
+ conclusion: { type: [string, "null"], description: null は実行中 }
steps: { type: array, items: { $ref: "#/components/schemas/StepRow" } }
gates:
type: object
@@ -962,44 +828,37 @@ components:
required: [state, at]
properties:
state: { type: string, enum: [none, waiting, answered] }
- at: { type: ["string", "null"], format: date-time }
+ at: { type: [string, "null"], format: date-time }
delegated_decisions: { type: integer, description: AI が代わりに判断した問い合わせの数 }
usage:
type: object
required: [input_tokens, output_tokens, cost_usd]
- properties:
- input_tokens: { type: integer }
- output_tokens: { type: integer }
- cost_usd: { type: number }
+ properties: { input_tokens: { type: integer }, output_tokens: { type: integer }, cost_usd: { type: number } }
omitted_steps: { type: integer, description: 上限で省いた古い工程の数 }
StepDetail:
allOf:
- - $ref: "#/components/schemas/StepRow"
+ - { $ref: "#/components/schemas/StepRow" }
- type: object
required: [files, files_omitted, commit_range, decisions, output, diff_url]
properties:
files:
- type: ["array", "null"]
+ type: [array, "null"]
description: null は記録なし、空の配列は触らなかった
items:
type: object
required: [path, added, deleted, url]
properties:
path: { type: string }
- added: { type: ["integer", "null"], description: null はバイナリ }
- deleted: { type: ["integer", "null"], description: null はバイナリ }
- url: { type: ["string", "null"], format: uri, description: コミットの範囲が無ければ null }
- files_omitted: { type: ["integer", "null"] }
+ added: { type: [integer, "null"], description: null はバイナリ }
+ deleted: { type: [integer, "null"], description: null はバイナリ }
+ url: { type: [string, "null"], format: uri, description: コミットの範囲が無ければ null }
+ files_omitted: { type: [integer, "null"] }
commit_range:
oneOf:
- - type: object
- required: [from, to]
- properties:
- from: { type: string }
- to: { type: string }
- - type: "null"
+ - { type: object, required: [from, to], properties: { from: { type: string }, to: { type: string } } }
+ - { type: "null" }
decisions:
- type: ["array", "null"]
+ type: [array, "null"]
items:
type: object
required: [question, answer, by]
@@ -1015,8 +874,8 @@ components:
kind: { type: string, enum: [report, log] }
text: { type: string }
truncated: { type: boolean }
- - type: "null"
- diff_url: { type: ["string", "null"], format: uri }
+ - { type: "null" }
+ diff_url: { type: [string, "null"], format: uri }
Backlog:
type: object
required: [repository_id, repo, ranking, refinements]
@@ -1039,7 +898,7 @@ components:
rank: { type: integer, minimum: 1 }
number: { type: integer }
title: { type: string }
- - type: "null"
+ - { type: "null" }
refinements:
type: array
description: 棚卸しの工程(step が refine)の記録を新しい順に
@@ -1052,61 +911,47 @@ components:
at: { type: string, format: date-time }
exit_code: { type: integer }
summary: { type: string }
- pull_request:
- oneOf:
- - $ref: "#/components/schemas/PullRequestLink"
- - type: "null"
+ pull_request: { oneOf: [{ $ref: "#/components/schemas/PullRequestLink" }, { type: "null" }] }
CancelResult:
type: object
required: [state]
- properties:
- state: { type: string, enum: [cancelled, running] }
+ properties: { state: { type: string, enum: [cancelled, running] } }
GitHubToken:
type: object
required: [token, expires_at]
- properties:
- token: { type: string }
- expires_at: { type: string, format: date-time }
- StateRequest:
- type: object
- required: [op]
- properties:
- op: { type: string, enum: [put, get] }
+ properties: { token: { type: string }, expires_at: { type: string, format: date-time } }
+ StateRequest: { type: object, required: [op], properties: { op: { type: string, enum: [put, get] } } }
ApprovalAnswer:
type: object
required: [kind]
description: >-
- kind が gate1 / gate2 なら decision が要り、ランナーはスプリント状態ファイルへ sprint-state.py gate で
- gate(無ければ kind を NDF の記録の名前へ写した「関門 1」/「関門 2」)の名前で書く。rejected なら差し戻しと
- body を conductor の再開の指示へ渡す。decision なら body を conductor への利用者の判断として渡す。
+ kind が gate1 / gate2 なら decision が要り、ランナーはスプリント状態ファイルへ sprint-state.py gate で gate(無ければ kind を NDF の記録の名前へ写した「関門 1」/「関門 2」)の名前で書く。rejected なら差し戻しと body を conductor の再開の指示へ渡す。decision なら body を conductor への利用者の判断として渡す。
properties:
kind: { type: string, enum: [gate1, gate2, decision] }
decision: { type: string, enum: [approved, rejected] }
- body: { type: ["string", "null"], description: 承認の理由か判断の本文 }
+ body: { type: [string, "null"], description: 承認の理由か判断の本文 }
gate: { type: string, description: スプリントの状態に書く関門の名前(省略時は kind から「関門 1」/「関門 2」) }
SignedUrl:
type: object
required: [url, expires_at]
- properties:
- url: { type: string, format: uri }
- expires_at: { type: string, format: date-time }
+ properties: { url: { type: string, format: uri }, expires_at: { type: string, format: date-time } }
OrganizerAnswer:
type: object
required: [request_id, result, version, release, reason, at]
properties:
request_id: { type: string }
result: { type: string, enum: [released, rejected] }
- version: { type: ["string", "null"] }
- release: { type: ["string", "null"], description: 本番の Pull Request の URL }
- reason: { type: ["string", "null"] }
+ version: { type: [string, "null"] }
+ release: { type: [string, "null"], description: 本番の Pull Request の URL }
+ reason: { type: [string, "null"] }
at: { type: string, format: date-time }
OrganizerRange:
type: object
required: [seq, sha, version, prs, lanes]
properties:
seq: { type: integer }
- sha: { type: ["string", "null"] }
- version: { type: ["string", "null"] }
+ sha: { type: [string, "null"] }
+ version: { type: [string, "null"] }
prs: { type: array, items: { type: integer } }
lanes: { type: array, items: { type: string } }
OrganizerAnswers:
@@ -1125,9 +970,7 @@ components:
items:
type: object
required: [id, prs]
- properties:
- id: { type: string }
- prs: { type: array, items: { type: integer } }
+ properties: { id: { type: string }, prs: { type: array, items: { type: integer } } }
issues: { type: array, items: { type: integer } }
OrganizerBatch:
type: object
@@ -1138,3 +981,62 @@ components:
lanes: { type: array, items: { $ref: "#/components/schemas/OrganizerBatchLane" } }
presentations: { type: array, items: { type: object } }
choices: { type: array, items: { type: string, enum: [continue, reject] } }
+ ElementFailure:
+ type: object
+ description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない
+ required: [ok, status, error]
+ properties:
+ ok: { const: false }
+ status: { type: integer, enum: [400, 403, 404, 409, 422, 503] }
+ error:
+ type: object
+ required: [code, message]
+ properties:
+ code: { type: string, description: 機械の値 }
+ message: { type: string, description: 利用者に出す文 }
+ details: { type: object }
+ IssueCandidatesItem:
+ type: object
+ properties:
+ repository_id: { type: integer, minimum: 1 }
+ exclude: { type: array, items: { type: integer, minimum: 1 } }
+ required: [repository_id]
+ StartSprintRunItem:
+ type: object
+ required: [idempotency_key, repository_id, issues]
+ properties:
+ idempotency_key:
+ type: string
+ pattern: "^[0-9A-Za-z-]{1,64}$"
+ description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする
+ repository_id: { type: integer, minimum: 1 }
+ issues: { type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: { type: integer, minimum: 1 } }
+ RepositoryUpdateItem:
+ type: object
+ required: [repository_id, max_concurrent_runs]
+ additionalProperties: false
+ properties:
+ repository_id: { type: integer, minimum: 1 }
+ max_concurrent_runs: { type: [integer, "null"], minimum: 1, maximum: 100 }
+ SprintRunRef:
+ type: object
+ required: [sprint_run_id]
+ additionalProperties: false
+ properties: { sprint_run_id: { type: string, minLength: 1 } }
+ Removed: { type: object, required: [removed], properties: { removed: { const: true } } }
+ SprintRunRow:
+ type: object
+ required: [sprint_run_id, repository_id, issues, state, failure_reason, created_at]
+ properties:
+ sprint_run_id: { type: string }
+ repository_id: { type: integer }
+ issues: { type: array, items: { type: integer } }
+ state:
+ type: string
+ enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled]
+ failure_reason: { type: [string, "null"] }
+ created_at: { type: string, format: date-time }
+ repo: { type: string, description: owner/name }
+ current_phase: { type: [string, "null"], description: 最後の phase.changed のフェーズの名前。null は始まる前。5 つの名前に無い値もそのまま返す }
+ current_step: { type: [string, "null"], description: 最後の step.recorded のステップ }
+ last_event_at: { type: [string, "null"], format: date-time, description: 最後の事象の時刻 }
diff --git a/factory/contracts/package.json b/factory/contracts/package.json
index 99b1d6f47..83b3e6283 100644
--- a/factory/contracts/package.json
+++ b/factory/contracts/package.json
@@ -6,7 +6,8 @@
"exports": {
".": "./src/index.ts",
"./phases": "./src/phases.ts",
- "./views": "./src/views.ts"
+ "./views": "./src/views.ts",
+ "./operations": "./src/operations.ts"
},
"scripts": {
"generate": "node scripts/generate.ts",
@@ -15,7 +16,9 @@
"check": "npm run typecheck && npm run test"
},
"devDependencies": {
+ "@types/js-yaml": "^4.0.9",
"@types/node": "^24.19.2",
+ "js-yaml": "^4.3.2",
"json-schema-to-typescript": "^16.0.0",
"typescript": "^7.0.2",
"vitest": "^5.0.5"
diff --git a/factory/contracts/scripts/generate.ts b/factory/contracts/scripts/generate.ts
index 075581fc7..d617f0b45 100644
--- a/factory/contracts/scripts/generate.ts
+++ b/factory/contracts/scripts/generate.ts
@@ -1,9 +1,11 @@
-// JSON Schema(schemas/)から TypeScript の型(src/generated/)を作る。定義が正で、型は生成物(決定 2)
-// node scripts/generate.ts 生成した型を書く
-// node scripts/generate.ts --check 書かずに、コミットした型と食い違えば 1 で終わる
+// JSON Schema(schemas/)から TypeScript の型(src/generated/)を、契約(openapi/)から操作の表を作る。
+// 定義が正で、型と表は生成物(決定 2・#2384 の決定 5)
+// node scripts/generate.ts 生成した型と表を書く
+// node scripts/generate.ts --check 書かずに、コミットした型か表と食い違えば 1 で終わる
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { compile, type JSONSchema } from "json-schema-to-typescript";
+import { generateOperations, operationsPath } from "./operations.ts";
const root = fileURLToPath(new URL("..", import.meta.url));
@@ -37,6 +39,8 @@ export async function staleSchemas(): Promise {
const committed = await readFile(generatedPath(name), "utf8").catch(() => null);
if (committed !== (await generateOne(name))) stale.push(name);
}
+ const committed = await readFile(operationsPath, "utf8").catch(() => null);
+ if (committed !== (await generateOperations())) stale.push("operations");
return stale;
}
@@ -50,6 +54,7 @@ async function main(): Promise {
return;
}
for (const name of SCHEMAS) await writeFile(generatedPath(name), await generateOne(name));
+ await writeFile(operationsPath, await generateOperations());
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await main();
diff --git a/factory/contracts/scripts/operations.ts b/factory/contracts/scripts/operations.ts
new file mode 100644
index 000000000..3ab9345bf
--- /dev/null
+++ b/factory/contracts/scripts/operations.ts
@@ -0,0 +1,123 @@
+// 契約(openapi/*.yaml)から操作の表(src/generated/operations.ts)を作る(#2384 の決定 5)。
+// 利用者向けの操作(x-ndf-access を持つ操作)だけを取り、画面・API・MCP・検査が同じ表を読む
+import { readdir, readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import yaml from "js-yaml";
+
+const root = fileURLToPath(new URL("..", import.meta.url));
+export const openapiDir = `${root}openapi`;
+export const operationsPath = `${root}src/generated/operations.ts`;
+
+const METHODS = ["get", "post", "put", "patch", "delete"] as const;
+const ACCESS = new Set(["read", "write", "human"]);
+
+type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
+type Obj = { [k: string]: Json };
+
+export interface GeneratedParameter {
+ name: string;
+ required: boolean;
+ array: boolean;
+ description: string | null;
+ schema: Json;
+}
+
+export interface GeneratedOperation {
+ operationId: string;
+ method: string;
+ path: string;
+ kind: string;
+ access: string;
+ keys: string[];
+ idField: string | null;
+ summary: string;
+ description: string;
+ parameters: GeneratedParameter[];
+ itemSchema: Json | null;
+ source: string;
+}
+
+const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
+
+// 同じファイルの中の $ref(#/components/...)をほどく。ファイルの外の参照は利用者向けの操作に無い
+function deref(doc: Obj, value: Json, seen: string[] = []): Json {
+ if (Array.isArray(value)) return value.map((v) => deref(doc, v, seen));
+ if (!isObj(value)) return value;
+ const ref = value.$ref;
+ if (typeof ref === "string") {
+ if (!ref.startsWith("#/")) throw new Error(`ファイルの外の参照は操作の表に入れられません: ${ref}`);
+ if (seen.includes(ref)) throw new Error(`参照が巡っています: ${ref}`);
+ let node: Json = doc;
+ for (const part of ref.slice(2).split("/")) node = isObj(node) ? (node[part] ?? null) : null;
+ if (node === null) throw new Error(`参照が見つかりません: ${ref}`);
+ return deref(doc, node, [...seen, ref]);
+ }
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deref(doc, v, seen)]));
+}
+
+// 種類は経路の形とメソッドから決める(契約の表の「操作の種類と形」)。POST は、作った識別子を返すか、
+// 同じ経路に別のメソッドがあれば作成、それ以外(経路の末尾が動詞)は操作
+function kindOf(method: string, path: string, op: Obj, pathItem: Obj): string {
+ if (method === "get") return Array.isArray(op["x-ndf-keys"]) ? "select" : "list";
+ if (method === "delete") return "delete";
+ if (method === "put" || method === "patch") return "update";
+ const others = METHODS.filter((m) => m !== "post" && isObj(pathItem[m]));
+ return typeof op["x-ndf-id"] === "string" || others.length > 0 ? "create" : "action";
+}
+
+export async function readOperations(dir = openapiDir): Promise {
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".yaml") && !f.startsWith(".")).sort();
+ const out: GeneratedOperation[] = [];
+ for (const file of files) {
+ const doc = yaml.load(await readFile(`${dir}/${file}`, "utf8")) as Obj;
+ const paths = isObj(doc.paths) ? doc.paths : {};
+ for (const [path, item] of Object.entries(paths)) {
+ if (!isObj(item)) continue;
+ for (const method of METHODS) {
+ const op = item[method];
+ if (!isObj(op) || op["x-ndf-access"] === undefined) continue;
+ const access = String(op["x-ndf-access"]);
+ if (!ACCESS.has(access)) throw new Error(`${op.operationId}: x-ndf-access は read・write・human のどれかです`);
+ const params = (Array.isArray(op.parameters) ? op.parameters : []).map((p) => deref(doc, p)).filter(isObj);
+ const body = isObj(op.requestBody) ? deref(doc, op.requestBody) : null;
+ const bodySchema = isObj(body) && isObj(body.content) && isObj(body.content["application/json"]) ? body.content["application/json"].schema : null;
+ out.push({
+ operationId: String(op.operationId),
+ method: method.toUpperCase(),
+ path,
+ kind: kindOf(method, path, op, item),
+ access,
+ keys: Array.isArray(op["x-ndf-keys"]) ? op["x-ndf-keys"].map(String) : [],
+ idField: typeof op["x-ndf-id"] === "string" ? op["x-ndf-id"] : null,
+ summary: String(op.summary ?? ""),
+ description: String(op.description ?? ""),
+ parameters: params
+ .filter((p) => p.in === "query")
+ .map((p) => ({
+ name: String(p.name),
+ required: p.required === true,
+ array: isObj(p.schema) && p.schema.type === "array",
+ description: typeof p.description === "string" ? p.description : null,
+ schema: p.schema ?? null,
+ })),
+ itemSchema: isObj(bodySchema) ? (bodySchema.items ?? null) : null,
+ source: file,
+ });
+ }
+ }
+ }
+ return out.sort((a, b) => a.operationId.localeCompare(b.operationId));
+}
+
+const BANNER = "// 生成したファイル。手で直さない。契約(openapi/)を直して `npm run generate -w contracts` で作り直す";
+
+export async function generateOperations(dir = openapiDir): Promise {
+ const ops = await readOperations(dir);
+ return [
+ BANNER,
+ 'import type { Operation } from "../operations.ts";',
+ "",
+ `export const OPERATIONS = ${JSON.stringify(ops, null, 2)} as const satisfies readonly Operation[];`,
+ "",
+ ].join("\n");
+}
diff --git a/factory/contracts/src/generated/operations.ts b/factory/contracts/src/generated/operations.ts
new file mode 100644
index 000000000..06b5b4cbe
--- /dev/null
+++ b/factory/contracts/src/generated/operations.ts
@@ -0,0 +1,1227 @@
+// 生成したファイル。手で直さない。契約(openapi/)を直して `npm run generate -w contracts` で作り直す
+import type { Operation } from "../operations.ts";
+
+export const OPERATIONS = [
+ {
+ "operationId": "addSeat",
+ "method": "POST",
+ "path": "/accounts/{accountId}/seats",
+ "kind": "create",
+ "access": "human",
+ "keys": [
+ "user_id"
+ ],
+ "idField": null,
+ "summary": "席を足す",
+ "description": "チームの持ち主が、既に 1 度ログインした利用者を ID で指して席を足す。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "user_id",
+ "seat_type"
+ ],
+ "properties": {
+ "user_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "seat_type": {
+ "type": "string",
+ "enum": [
+ "standard",
+ "premium"
+ ]
+ }
+ }
+ },
+ "source": "accounts.yaml"
+ },
+ {
+ "operationId": "answerLoopPending",
+ "method": "POST",
+ "path": "/accounts/{accountId}/loops/answers",
+ "kind": "action",
+ "access": "human",
+ "keys": [
+ "loop_id",
+ "issue"
+ ],
+ "idField": null,
+ "summary": "人の判断待ちの課題に答える",
+ "description": "答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "loop_id",
+ "issue",
+ "decision"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "loop_id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "issue": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "decision": {
+ "type": "string",
+ "enum": [
+ "approved",
+ "rejected"
+ ]
+ },
+ "pr": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "source": "loops.yaml"
+ },
+ {
+ "operationId": "cancelLoop",
+ "method": "POST",
+ "path": "/accounts/{accountId}/loops/cancel",
+ "kind": "action",
+ "access": "write",
+ "keys": [
+ "loop_id"
+ ],
+ "idField": null,
+ "summary": "開発ループを止める",
+ "description": "動いている実行があればスプリントの実行の取り消しと同じ形で止め、停止の後に cancelled へ移す。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "loop_id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "loop_id": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "source": "loops.yaml"
+ },
+ {
+ "operationId": "cancelSprintRun",
+ "method": "POST",
+ "path": "/accounts/{accountId}/sprint-runs/cancel",
+ "kind": "action",
+ "access": "write",
+ "keys": [
+ "sprint_run_id"
+ ],
+ "idField": null,
+ "summary": "スプリントの実行を取り消す",
+ "description": "動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "sprint_run_id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "sprint_run_id": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "changeSeat",
+ "method": "PATCH",
+ "path": "/accounts/{accountId}/seats",
+ "kind": "update",
+ "access": "human",
+ "keys": [
+ "user_id"
+ ],
+ "idField": null,
+ "summary": "席の種類を変える",
+ "description": "チームの持ち主だけが変えられる。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "user_id",
+ "seat_type"
+ ],
+ "properties": {
+ "user_id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "seat_type": {
+ "type": "string",
+ "enum": [
+ "standard",
+ "premium"
+ ]
+ }
+ }
+ },
+ "source": "accounts.yaml"
+ },
+ {
+ "operationId": "createDelegation",
+ "method": "POST",
+ "path": "/delegations",
+ "kind": "create",
+ "access": "human",
+ "keys": [
+ "idempotency_key"
+ ],
+ "idField": "delegation.credential_id",
+ "summary": "委任の資格情報を発行する",
+ "description": "値(ndfd_<資格情報の ID>_<秘密>)は発行の結果で 1 度だけ返し、平文を保存しない。同じ冪等の鍵の 2 回目は同じ資格情報を返し、credential を null にする。期限は 300〜3,600 秒。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "client_name",
+ "scope",
+ "expires_in_seconds",
+ "idempotency_key"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "client_name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ "description": "人が付けるクライアントの名前。操作の記録には写さない"
+ },
+ "scope": {
+ "type": "string",
+ "description": "read(読み取りだけ)・read_write(読み書き)。どちらも人だけが確定できる操作は呼べない",
+ "enum": [
+ "read",
+ "read_write"
+ ]
+ },
+ "expires_in_seconds": {
+ "type": "integer",
+ "minimum": 300,
+ "maximum": 3600
+ },
+ "idempotency_key": {
+ "type": "string",
+ "pattern": "^[0-9A-Za-z-]{1,64}$",
+ "description": "作成の冪等の鍵"
+ }
+ }
+ },
+ "source": "delegations.yaml"
+ },
+ {
+ "operationId": "createTeam",
+ "method": "POST",
+ "path": "/teams",
+ "kind": "create",
+ "access": "human",
+ "keys": [
+ "idempotency_key"
+ ],
+ "idField": "account_id",
+ "summary": "チームのアカウントを作る",
+ "description": "作った利用者がチームの持ち主になり、持ち主の席が 1 つできる。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "idempotency_key",
+ "name",
+ "seat_type"
+ ],
+ "properties": {
+ "idempotency_key": {
+ "type": "string",
+ "pattern": "^[0-9A-Za-z-]{1,64}$",
+ "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64
+ },
+ "seat_type": {
+ "type": "string",
+ "enum": [
+ "standard",
+ "premium"
+ ]
+ }
+ }
+ },
+ "source": "accounts.yaml"
+ },
+ {
+ "operationId": "deleteModelCredential",
+ "method": "DELETE",
+ "path": "/accounts/{accountId}/model-credential",
+ "kind": "delete",
+ "access": "human",
+ "keys": [],
+ "idField": null,
+ "summary": "持ち込んだキーを消す",
+ "description": "復元の期間を置かずに消す(決定 14)。無くても成功の結果を返す。",
+ "parameters": [],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "getAccount",
+ "method": "GET",
+ "path": "/accounts",
+ "kind": "select",
+ "access": "read",
+ "keys": [
+ "account_id"
+ ],
+ "idField": null,
+ "summary": "アカウントの料金プラン・実行の形態・使える量・席",
+ "description": "個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。",
+ "parameters": [
+ {
+ "name": "account_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "accounts.yaml"
+ },
+ {
+ "operationId": "getAuthorizeUrl",
+ "method": "GET",
+ "path": "/accounts/{accountId}/github/authorize-url",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "GitHub App の利用者の認可の URL",
+ "description": "state は利用者とアカウントを署名した 10 分の値。持ち主だけが使える。",
+ "parameters": [],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "getBacklog",
+ "method": "GET",
+ "path": "/accounts/{accountId}/backlogs",
+ "kind": "select",
+ "access": "read",
+ "keys": [
+ "repository_id"
+ ],
+ "idField": null,
+ "summary": "バックログ",
+ "description": "リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリの要素も、他人のアカウントと同じ本文の forbidden の結果になる。",
+ "parameters": [
+ {
+ "name": "repository_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "getInstallUrl",
+ "method": "GET",
+ "path": "/accounts/{accountId}/github/install-url",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "GitHub App の導入の画面の URL",
+ "description": "GitHub App の導入の URL。要素 1 個の配列で返す。",
+ "parameters": [],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "getLoop",
+ "method": "GET",
+ "path": "/accounts/{accountId}/loops/detail",
+ "kind": "select",
+ "access": "read",
+ "keys": [
+ "loop_id"
+ ],
+ "idField": null,
+ "summary": "開発ループの詳細",
+ "description": "締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。",
+ "parameters": [
+ {
+ "name": "loop_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "loops.yaml"
+ },
+ {
+ "operationId": "getMe",
+ "method": "GET",
+ "path": "/me",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "自分の利用者と、席を持つアカウントの一覧",
+ "description": "初めての要求では、Free・local の個人のアカウントを 1 つ作る。",
+ "parameters": [],
+ "itemSchema": null,
+ "source": "accounts.yaml"
+ },
+ {
+ "operationId": "getModelCredential",
+ "method": "GET",
+ "path": "/accounts/{accountId}/model-credential",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "持ち込んだキーの有無",
+ "description": "登録済みか・種類・更新の時刻・外部 ID だけを返し、値も置き場の ARN も返さない(I38)。",
+ "parameters": [],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "getSprintRunDetail",
+ "method": "GET",
+ "path": "/accounts/{accountId}/sprint-runs/detail",
+ "kind": "select",
+ "access": "read",
+ "keys": [
+ "sprint_run_id"
+ ],
+ "idField": null,
+ "summary": "スプリントの詳細",
+ "description": "フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の操作(getStepDetail)で読む。 step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 無い実行・アカウントに属さない実行の要素は、他人のアカウントと同じ本文の forbidden の結果になる。",
+ "parameters": [
+ {
+ "name": "sprint_run_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "getStepDetail",
+ "method": "GET",
+ "path": "/accounts/{accountId}/sprint-runs/steps",
+ "kind": "select",
+ "access": "read",
+ "keys": [
+ "step_key"
+ ],
+ "idField": null,
+ "summary": "工程の中身",
+ "description": "1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 step_key は事象の idempotency_key。記録の無い項目は null。 無い工程・別の実行の工程の要素も、他人のアカウントと同じ本文の forbidden の結果になる。",
+ "parameters": [
+ {
+ "name": "sprint_run_id",
+ "required": true,
+ "array": false,
+ "description": "工程を持つ実行。他人の実行なら要求全体が 403",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "step_key",
+ "required": true,
+ "array": true,
+ "description": "工程の鍵(事象の idempotency_key)。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "listDelegations",
+ "method": "GET",
+ "path": "/delegations",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "自分が発行した委任の資格情報の一覧",
+ "description": "新しい順に 100 件まで。値と秘密のハッシュは返さない。",
+ "parameters": [],
+ "itemSchema": null,
+ "source": "delegations.yaml"
+ },
+ {
+ "operationId": "listLoops",
+ "method": "GET",
+ "path": "/accounts/{accountId}/loops",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "リポジトリの開発ループの一覧",
+ "description": "新しい順に 50 件。",
+ "parameters": [
+ {
+ "name": "repository_id",
+ "required": true,
+ "array": false,
+ "description": null,
+ "schema": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "loops.yaml"
+ },
+ {
+ "operationId": "listRepositories",
+ "method": "GET",
+ "path": "/accounts/{accountId}/repositories",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "登録したリポジトリの一覧",
+ "description": "リポジトリごとの終わりの状態でないスプリントの実行の数を添える。",
+ "parameters": [],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "listRepositoryApprovalRequests",
+ "method": "GET",
+ "path": "/accounts/{accountId}/approval-requests",
+ "kind": "select",
+ "access": "read",
+ "keys": [
+ "repository_id"
+ ],
+ "idField": null,
+ "summary": "リポジトリの承認と問いの一覧",
+ "description": "同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。",
+ "parameters": [
+ {
+ "name": "repository_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "organizer.yaml"
+ },
+ {
+ "operationId": "listRepositoryReleases",
+ "method": "GET",
+ "path": "/accounts/{accountId}/releases",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "まとめた配布の履歴",
+ "description": "新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。",
+ "parameters": [
+ {
+ "name": "repository_id",
+ "required": true,
+ "array": false,
+ "description": null,
+ "schema": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ {
+ "name": "limit",
+ "required": false,
+ "array": false,
+ "description": null,
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "organizer.yaml"
+ },
+ {
+ "operationId": "listSprintRuns",
+ "method": "GET",
+ "path": "/accounts/{accountId}/sprint-runs",
+ "kind": "list",
+ "access": "read",
+ "keys": [],
+ "idField": null,
+ "summary": "スプリントの実行の一覧",
+ "description": "新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。",
+ "parameters": [
+ {
+ "name": "repository_id",
+ "required": false,
+ "array": false,
+ "description": "省くとアカウントの新しい順の 50 件",
+ "schema": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "postIssueCandidates",
+ "method": "POST",
+ "path": "/accounts/{accountId}/issue-candidates",
+ "kind": "action",
+ "access": "write",
+ "keys": [
+ "repository_id"
+ ],
+ "idField": null,
+ "summary": "AI が選んだ課題の候補",
+ "description": "棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。言語モデルを呼び費用が掛かるため、扱いは書き込み。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "properties": {
+ "repository_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "exclude": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "required": [
+ "repository_id"
+ ]
+ },
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "postRepositoryCandidates",
+ "method": "POST",
+ "path": "/accounts/{accountId}/github/candidates",
+ "kind": "action",
+ "access": "write",
+ "keys": [],
+ "idField": null,
+ "summary": "認可の戻りから登録の候補を返す",
+ "description": "導入の対象で利用者の届くリポジトリと、10 分の候補の証明を返す。利用者の token は保存しない。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "code",
+ "state"
+ ],
+ "properties": {
+ "code": {
+ "type": "string",
+ "minLength": 1
+ },
+ "state": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "putModelCredential",
+ "method": "PUT",
+ "path": "/accounts/{accountId}/model-credential",
+ "kind": "update",
+ "access": "human",
+ "keys": [],
+ "idField": null,
+ "summary": "キーを保存・差し替える",
+ "description": "Anthropic API キーか AWS のロールの ARN と地域のどちらか 1 つ。持ち主だけが使える。",
+ "parameters": [],
+ "itemSchema": {
+ "oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "kind",
+ "api_key"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "anthropic_api_key"
+ },
+ "api_key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 512
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "kind",
+ "role_arn",
+ "region"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "aws_role"
+ },
+ "role_arn": {
+ "type": "string",
+ "maxLength": 2048,
+ "pattern": "^arn:aws[a-z-]*:iam::\\d{12}:role/.+$"
+ },
+ "region": {
+ "type": "string",
+ "maxLength": 32,
+ "pattern": "^[a-z]{2}(-[a-z]+)+-\\d$"
+ }
+ }
+ }
+ ]
+ },
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "registerRepository",
+ "method": "POST",
+ "path": "/accounts/{accountId}/repositories",
+ "kind": "create",
+ "access": "write",
+ "keys": [
+ "repository_id"
+ ],
+ "idField": null,
+ "summary": "リポジトリを登録する",
+ "description": "候補の証明と、登録の直前の導入の token の作成で確かめる(I33)。同じリポジトリは 409(I32)。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "proof",
+ "repository_id"
+ ],
+ "properties": {
+ "proof": {
+ "type": "string"
+ },
+ "repository_id": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "removeRepository",
+ "method": "DELETE",
+ "path": "/accounts/{accountId}/repositories",
+ "kind": "delete",
+ "access": "write",
+ "keys": [
+ "repository_id"
+ ],
+ "idField": null,
+ "summary": "リポジトリの登録を外す",
+ "description": "終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。",
+ "parameters": [
+ {
+ "name": "repository_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "removeSeat",
+ "method": "DELETE",
+ "path": "/accounts/{accountId}/seats",
+ "kind": "delete",
+ "access": "human",
+ "keys": [
+ "user_id"
+ ],
+ "idField": null,
+ "summary": "席を外す",
+ "description": "チームの持ち主だけが外せる。持ち主自身の席は外せない。",
+ "parameters": [
+ {
+ "name": "user_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "accounts.yaml"
+ },
+ {
+ "operationId": "resolveStalledRelease",
+ "method": "POST",
+ "path": "/accounts/{accountId}/releases/resolve",
+ "kind": "action",
+ "access": "human",
+ "keys": [
+ "repository_id",
+ "seq"
+ ],
+ "idField": null,
+ "summary": "止まった配布を解く",
+ "description": "配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "repository_id",
+ "seq",
+ "choice"
+ ],
+ "properties": {
+ "repository_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "seq": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "choice": {
+ "type": "string",
+ "enum": [
+ "continue",
+ "reject"
+ ]
+ },
+ "body": {
+ "type": "string",
+ "maxLength": 4000
+ }
+ }
+ },
+ "source": "organizer.yaml"
+ },
+ {
+ "operationId": "revokeDelegation",
+ "method": "DELETE",
+ "path": "/delegations",
+ "kind": "delete",
+ "access": "human",
+ "keys": [
+ "credential_id"
+ ],
+ "idField": null,
+ "summary": "委任の資格情報を取り消す",
+ "description": "取り消しはその後の最初の要求から効く。期限切れと取り消し済みの資格情報は何も変えずに成功を返す。他人の資格情報は forbidden の結果になる。",
+ "parameters": [
+ {
+ "name": "credential_id",
+ "required": true,
+ "array": true,
+ "description": "要素の鍵。繰り返して 1〜100 個を指す",
+ "schema": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "itemSchema": null,
+ "source": "delegations.yaml"
+ },
+ {
+ "operationId": "setExecutionMode",
+ "method": "PATCH",
+ "path": "/accounts",
+ "kind": "update",
+ "access": "write",
+ "keys": [
+ "account_id"
+ ],
+ "idField": null,
+ "summary": "実行の形態を変える",
+ "description": "アカウントの持ち主だけが変えられる。今と同じ値なら履歴を足さずに 200 を返す。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "account_id",
+ "execution_mode"
+ ],
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "execution_mode": {
+ "type": "string",
+ "description": "local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock)",
+ "enum": [
+ "local",
+ "byok",
+ "managed"
+ ]
+ }
+ }
+ },
+ "source": "accounts.yaml"
+ },
+ {
+ "operationId": "startLoop",
+ "method": "POST",
+ "path": "/accounts/{accountId}/loops",
+ "kind": "create",
+ "access": "write",
+ "keys": [
+ "idempotency_key"
+ ],
+ "idField": "loop_id",
+ "summary": "開発ループを始める",
+ "description": "マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 枠で待つときも成功の結果(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "repository_id",
+ "idempotency_key",
+ "name",
+ "milestones"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "repository_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "idempotency_key": {
+ "type": "string",
+ "pattern": "^[0-9A-Za-z-]{1,64}$",
+ "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする"
+ },
+ "name": {
+ "type": "string",
+ "pattern": "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$"
+ },
+ "milestones": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 10,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 100,
+ "pattern": "^[^,]+$"
+ }
+ },
+ "budget": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "laps": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "exclusiveMinimum": 0
+ },
+ "usd": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "exclusiveMinimum": 0
+ },
+ "hours": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "exclusiveMinimum": 0
+ }
+ }
+ }
+ }
+ },
+ "source": "loops.yaml"
+ },
+ {
+ "operationId": "startNextLap",
+ "method": "POST",
+ "path": "/accounts/{accountId}/loops/next-lap",
+ "kind": "action",
+ "access": "write",
+ "keys": [
+ "loop_id"
+ ],
+ "idField": null,
+ "summary": "次の周回を始める(形態 local)",
+ "description": "次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "loop_id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "loop_id": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "source": "loops.yaml"
+ },
+ {
+ "operationId": "startSprintRun",
+ "method": "POST",
+ "path": "/accounts/{accountId}/sprint-runs",
+ "kind": "create",
+ "access": "write",
+ "keys": [
+ "idempotency_key"
+ ],
+ "idField": "sprint_run_id",
+ "summary": "スプリントの実行を始める",
+ "description": "確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "idempotency_key",
+ "repository_id",
+ "issues"
+ ],
+ "properties": {
+ "idempotency_key": {
+ "type": "string",
+ "pattern": "^[0-9A-Za-z-]{1,64}$",
+ "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする"
+ },
+ "repository_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "issues": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 20,
+ "uniqueItems": true,
+ "items": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ },
+ "source": "runs.yaml"
+ },
+ {
+ "operationId": "updateLoopBudget",
+ "method": "PATCH",
+ "path": "/accounts/{accountId}/loops",
+ "kind": "update",
+ "access": "write",
+ "keys": [
+ "loop_id"
+ ],
+ "idField": null,
+ "summary": "開発ループの予算を変える",
+ "description": "終わりの状態でないループにだけ効き、次のジョブの指示から NDF の予算へ渡る。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "loop_id",
+ "budget"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "loop_id": {
+ "type": "string",
+ "minLength": 1
+ },
+ "budget": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "laps": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "exclusiveMinimum": 0
+ },
+ "usd": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "exclusiveMinimum": 0
+ },
+ "hours": {
+ "type": [
+ "number",
+ "null"
+ ],
+ "exclusiveMinimum": 0
+ }
+ }
+ }
+ }
+ },
+ "source": "loops.yaml"
+ },
+ {
+ "operationId": "updateRepository",
+ "method": "PATCH",
+ "path": "/accounts/{accountId}/repositories",
+ "kind": "update",
+ "access": "write",
+ "keys": [
+ "repository_id"
+ ],
+ "idField": null,
+ "summary": "リポジトリの同時の実行の数を宣言する",
+ "description": "料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。",
+ "parameters": [],
+ "itemSchema": {
+ "type": "object",
+ "required": [
+ "repository_id",
+ "max_concurrent_runs"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "repository_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "max_concurrent_runs": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "source": "runs.yaml"
+ }
+] as const satisfies readonly Operation[];
diff --git a/factory/contracts/src/operations.ts b/factory/contracts/src/operations.ts
new file mode 100644
index 000000000..8cc39d71b
--- /dev/null
+++ b/factory/contracts/src/operations.ts
@@ -0,0 +1,83 @@
+// 操作の表(#2384 の決定 5)。契約から生成した利用者向けの操作の一覧を、画面・API・MCP・検査が同じく読む
+import { OPERATIONS } from "./generated/operations.ts";
+
+export { OPERATIONS };
+
+// 操作の扱い。read(読み取り)・write(書き込み)・human(人だけが確定できる。委任の資格情報からは断る)
+export type OperationAccess = "read" | "write" | "human";
+// 操作の種類。list は条件で集めた値の配列を返し、ほかは要素の結果を入力と同じ順に返す
+export type OperationKind = "list" | "select" | "create" | "update" | "delete" | "action";
+export type OperationMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+
+export interface OperationParameter {
+ readonly name: string;
+ readonly required: boolean;
+ // 鍵の query は繰り返す(?a=1&a=2)
+ readonly array: boolean;
+ readonly description: string | null;
+ readonly schema: unknown;
+}
+
+export interface Operation {
+ readonly operationId: string;
+ readonly method: OperationMethod;
+ // 経路の変数は {accountId} だけ
+ readonly path: string;
+ readonly kind: OperationKind;
+ readonly access: OperationAccess;
+ // 要素の鍵の項目の名前。鍵を持たない操作は空で、要素は必ず 1 個
+ readonly keys: readonly string[];
+ // 作成の成功の値の中の、作った識別子の項目(`.` で入れ子を指す)
+ readonly idField: string | null;
+ readonly summary: string;
+ readonly description: string;
+ readonly parameters: readonly OperationParameter[];
+ // 作成・更新・操作の本文の要素の定義(参照をほどいた JSON Schema)
+ readonly itemSchema: unknown;
+ readonly source: string;
+}
+
+export type OperationId = (typeof OPERATIONS)[number]["operationId"];
+
+// 1 回の要求で受け渡す要素の上限(要求の前提 3)
+export const MAX_ITEMS = 100;
+
+// 操作の記録の target_ids へ写してよい鍵の名前(契約の表の「操作の記録へ写す鍵」。決定 12)
+export const AUDIT_KEYS = [
+ "account_id",
+ "user_id",
+ "repository_id",
+ "sprint_run_id",
+ "step_key",
+ "loop_id",
+ "issue",
+ "seq",
+ "credential_id",
+ "idempotency_key",
+] as const;
+
+const byId = new Map(OPERATIONS.map((op) => [op.operationId, op as Operation]));
+
+export function operation(operationId: OperationId): Operation {
+ return byId.get(operationId)!;
+}
+
+export function findOperation(operationId: string): Operation | null {
+ return byId.get(operationId) ?? null;
+}
+
+// 経路の {accountId} を埋め、query を足した URL の経路を返す。配列の値は同じ名前で繰り返す
+export function operationUrl(op: Operation, accountId: string | null, query: Record = {}): string {
+ let path = op.path;
+ if (path.includes("{accountId}")) {
+ if (accountId === null) throw new Error(`${op.operationId} はアカウントの ID が要ります`);
+ path = path.replace("{accountId}", encodeURIComponent(accountId));
+ }
+ const params = new URLSearchParams();
+ for (const [name, value] of Object.entries(query)) {
+ if (value === undefined || value === null) continue;
+ for (const v of Array.isArray(value) ? value : [value]) params.append(name, String(v));
+ }
+ const qs = params.toString();
+ return qs ? `${path}?${qs}` : path;
+}
diff --git a/factory/contracts/tests/operations.test.ts b/factory/contracts/tests/operations.test.ts
new file mode 100644
index 000000000..632a63495
--- /dev/null
+++ b/factory/contracts/tests/operations.test.ts
@@ -0,0 +1,118 @@
+// 機能から起こした契約の形と操作の表(#2384 の受け入れ条件 4・5・16、不変条件 I10)
+import { readdir, readFile } from "node:fs/promises";
+import yaml from "js-yaml";
+import { describe, expect, it } from "vitest";
+import { openapiDir, readOperations } from "../scripts/operations.ts";
+import { AUDIT_KEYS, OPERATIONS, operation, operationUrl } from "../src/operations.ts";
+
+type Obj = Record;
+const METHODS = ["get", "post", "put", "patch", "delete"];
+
+async function allOperations(): Promise<{ file: string; path: string; method: string; op: Obj; doc: Obj }[]> {
+ const out = [];
+ for (const file of (await readdir(openapiDir)).filter((f) => f.endsWith(".yaml") && !f.startsWith("."))) {
+ const doc = yaml.load(await readFile(`${openapiDir}/${file}`, "utf8")) as Obj;
+ for (const [path, item] of Object.entries(doc.paths as Obj)) {
+ for (const method of METHODS) if (item[method]) out.push({ file, path, method, op: item[method] as Obj, doc });
+ }
+ }
+ return out;
+}
+
+function resolve(doc: Obj, schema: Obj): Obj {
+ if (typeof schema.$ref !== "string") return schema;
+ return schema.$ref
+ .slice(2)
+ .split("/")
+ .reduce((node: Obj, part: string) => node[part], doc);
+}
+
+describe("利用者向けの操作の形(受け入れ条件 4・5)", () => {
+ it("成功の応答と要求の本文が配列である", async () => {
+ const userOps = (await allOperations()).filter((o) => o.op["x-ndf-access"] !== undefined);
+ expect(userOps.length).toBe(OPERATIONS.length);
+ for (const { op, doc } of userOps) {
+ const ok = op.responses["200"];
+ expect(ok, `${op.operationId} の 200`).toBeDefined();
+ expect(resolve(doc, ok.content["application/json"].schema).type, op.operationId).toBe("array");
+ for (const status of Object.keys(op.responses)) expect(["200", "400", "401", "403", "503"], `${op.operationId} の ${status}`).toContain(status);
+ if (op.requestBody) {
+ const schema = resolve(doc, op.requestBody.content["application/json"].schema);
+ expect(schema.type, op.operationId).toBe("array");
+ expect([schema.minItems, schema.maxItems], op.operationId).toEqual([1, 100]);
+ }
+ }
+ });
+
+ it("経路の変数はアカウントの ID だけで、単一のレコードを経路で指す取得が無い", async () => {
+ for (const { path, op } of await allOperations()) {
+ if (op["x-ndf-access"] === undefined) continue;
+ const vars = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
+ expect(vars.filter((v) => v !== "accountId"), `${op.operationId} の ${path}`).toEqual([]);
+ }
+ });
+
+ it("要素を持つ操作の鍵が、query か本文の要素の項目にある", async () => {
+ for (const op of OPERATIONS) {
+ if (op.kind === "list") continue;
+ for (const key of op.keys) {
+ const inQuery = op.parameters.some((p) => p.name === key && p.array);
+ const item = op.itemSchema as { properties?: Record } | null;
+ expect(inQuery || Boolean(item?.properties?.[key]), `${op.operationId} の ${key}`).toBe(true);
+ }
+ }
+ });
+
+ it("ランナー向けの経路と受け口は操作の表に入らない", () => {
+ for (const op of OPERATIONS) expect(op.path.startsWith("/run-jobs/") || op.path.startsWith("/ingest/"), op.operationId).toBe(false);
+ });
+});
+
+describe("操作の扱い(受け入れ条件 16、決定 10)", () => {
+ it("人だけが確定できる操作は 10 で、ループの判断待ちへの答えを含む", () => {
+ const human = OPERATIONS.filter((op) => op.access === "human").map((op) => op.operationId);
+ expect(human.sort()).toEqual(
+ [
+ "addSeat",
+ "answerLoopPending",
+ "changeSeat",
+ "createDelegation",
+ "createTeam",
+ "deleteModelCredential",
+ "putModelCredential",
+ "removeSeat",
+ "resolveStalledRelease",
+ "revokeDelegation",
+ ].sort(),
+ );
+ expect(operation("answerLoopPending").access).toBe("human");
+ });
+
+ it("読み取りの操作は GET だけで、GET の操作はすべて読み取り", () => {
+ for (const op of OPERATIONS) expect(op.method === "GET", op.operationId).toBe(op.access === "read");
+ });
+});
+
+describe("操作の記録へ写す鍵(決定 12)", () => {
+ it("すべての操作の鍵と作った識別子の項目が、許可した鍵の一覧にある", () => {
+ const allowed: readonly string[] = AUDIT_KEYS;
+ for (const op of OPERATIONS) {
+ for (const key of op.keys) expect(allowed, `${op.operationId} の ${key}`).toContain(key);
+ if (op.idField) expect(allowed, `${op.operationId} の ${op.idField}`).toContain(op.idField.split(".").at(-1));
+ }
+ });
+});
+
+describe("操作の表の生成", () => {
+ it("契約から読んだ操作と表が一致する", async () => {
+ expect((await readOperations()).map((o) => o.operationId)).toEqual(OPERATIONS.map((o) => o.operationId));
+ });
+
+ it("経路を表から組み、鍵の query を繰り返す", () => {
+ expect(operationUrl(operation("getSprintRunDetail"), "a/1", { sprint_run_id: ["s1", "s2"] })).toBe(
+ "/accounts/a%2F1/sprint-runs/detail?sprint_run_id=s1&sprint_run_id=s2",
+ );
+ expect(operationUrl(operation("getMe"), null)).toBe("/me");
+ expect(() => operationUrl(operation("listLoops"), null)).toThrow();
+ });
+});
diff --git a/factory/package-lock.json b/factory/package-lock.json
index 9f99ff596..001c792b7 100644
--- a/factory/package-lock.json
+++ b/factory/package-lock.json
@@ -66,7 +66,9 @@
"ajv-formats": "^3.0.1"
},
"devDependencies": {
+ "@types/js-yaml": "^4.0.9",
"@types/node": "^24.19.2",
+ "js-yaml": "^4.3.2",
"json-schema-to-typescript": "^16.0.0",
"typescript": "^7.0.2",
"vitest": "^5.0.5"
@@ -99,6 +101,36 @@
}
}
},
+ "contracts/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "contracts/node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"contracts/node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
@@ -2502,6 +2534,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
From 8890d548ff0d2b95324466a0050a94fdf4d7da86 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 22:33:44 +0000
Subject: [PATCH 02/16] =?UTF-8?q?API=20=E3=81=A8=E7=94=BB=E9=9D=A2?=
=?UTF-8?q?=E3=81=AE=20API=20=E3=82=AF=E3=83=A9=E3=82=A4=E3=82=A2=E3=83=B3?=
=?UTF-8?q?=E3=83=88=E3=82=92=E6=93=8D=E4=BD=9C=E3=81=AE=E8=A1=A8=E3=81=8B?=
=?UTF-8?q?=E3=82=89=E7=B5=84=E3=81=BF=E3=80=81=E9=85=8D=E5=88=97=E3=81=A7?=
=?UTF-8?q?=E5=8F=97=E3=81=91=E6=B8=A1=E3=81=99=EF=BC=88#2384=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
操作の登録の層(routes/operation.ts)が、経路の登録・要素の数と重複の検査・経路のアカウントの解決・
要素ごとの実行を、すべての利用者向けの操作に同じく掛ける。スプリントの実行・開発ループ・チームの作成に
作成の冪等の鍵を足し、鍵の行を作るものと同じトランザクションで書く。画面の API クライアントは要求を
操作の表から組み、詳細・工程の中身・バックログ・ループの詳細を要素の数によらず 1 回の要求で読む。
ソースの検査に、要求の経路を文字列で直に組む書き方を落とす規則を足した。
Co-Authored-By: Claude Opus 5.5
---
factory/api/src/accounts/memory-store.ts | 16 +-
factory/api/src/accounts/service.ts | 25 ++-
factory/api/src/accounts/store.ts | 26 ++-
factory/api/src/auth/resolve.ts | 11 +-
factory/api/src/auth/verify.test.ts | 2 +-
factory/api/src/handlers/api.test.ts | 129 +++++++-------
factory/api/src/idempotency.ts | 41 +++++
factory/api/src/routes/accounts.ts | 92 +++++-----
factory/api/src/routes/body.ts | 13 --
factory/api/src/routes/elements.ts | 35 ++++
factory/api/src/routes/factory.test.ts | 183 +++++++++++++-------
factory/api/src/routes/factory.ts | 178 ++++++++-----------
factory/api/src/routes/me.ts | 19 +-
factory/api/src/routes/operation.ts | 126 ++++++++++++++
factory/api/src/routes/sprint-views.test.ts | 104 +++++++----
factory/api/src/routes/sprint-views.ts | 117 ++++++-------
factory/api/src/routes/teams.ts | 23 ++-
factory/api/src/runs/control.ts | 25 ++-
factory/api/src/runs/dynamo-table.ts | 31 +++-
factory/api/src/runs/loops.ts | 21 ++-
factory/api/src/runs/memory-table.ts | 20 ++-
factory/api/src/runs/model.ts | 5 +-
factory/api/src/runs/table.ts | 6 +-
factory/api/src/testing/app.ts | 33 +++-
factory/web/scripts/check-source.ts | 22 ++-
factory/web/src/App.test.tsx | 44 ++---
factory/web/src/api.test.ts | 108 ++++++++++++
factory/web/src/api.ts | 135 +++++++++------
factory/web/src/checks.test.ts | 9 +
29 files changed, 1117 insertions(+), 482 deletions(-)
create mode 100644 factory/api/src/idempotency.ts
delete mode 100644 factory/api/src/routes/body.ts
create mode 100644 factory/api/src/routes/elements.ts
create mode 100644 factory/api/src/routes/operation.ts
create mode 100644 factory/web/src/api.test.ts
diff --git a/factory/api/src/accounts/memory-store.ts b/factory/api/src/accounts/memory-store.ts
index 613568fe0..b3eb4d659 100644
--- a/factory/api/src/accounts/memory-store.ts
+++ b/factory/api/src/accounts/memory-store.ts
@@ -1,5 +1,6 @@
import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts";
import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts";
+import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts";
// DynamoAccountStore と同じ条件で書く、メモリの上の保存。テストと手元の確かめに使う
// beforeWrite は書き込みの直前に呼ばれる。投げると書き込みは何も残さない(トランザクションの失敗)
@@ -8,6 +9,8 @@ export class MemoryAccountStore implements AccountStore {
readonly accounts = new Map();
readonly seats = new Map();
readonly history: HistoryRecord[] = [];
+ // 作成の冪等の鍵の行(TTL で消さない)
+ readonly idempotency = new Map();
beforeWrite: ((op: string) => void | Promise) | null = null;
private static seatKey(accountId: string, userId: string) {
@@ -56,12 +59,21 @@ export class MemoryAccountStore implements AccountStore {
return [...this.seats.values()].filter((s) => s.userId === userId).map((s) => s.accountId);
}
- async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]) {
+ async getIdempotent(ref: IdempotencyRef, now: string) {
+ return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now);
+ }
+
+ async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim) {
const key = MemoryAccountStore.seatKey(ownerSeat.accountId, ownerSeat.userId);
+ const idemFree = () => {
+ const row = idempotency && this.idempotency.get(idempotencyPk(idempotency));
+ return !row || row.ttl < epochSeconds(idempotency!.createdAt);
+ };
await this.write(
"createTeam",
- () => !this.accounts.has(account.accountId) && !this.seats.has(key),
+ () => !this.accounts.has(account.accountId) && !this.seats.has(key) && idemFree(),
() => {
+ if (idempotency) this.idempotency.set(idempotencyPk(idempotency), { ...idempotency });
this.accounts.set(account.accountId, { ...account });
this.seats.set(key, { ...ownerSeat });
},
diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts
index 241a98f96..85de493e0 100644
--- a/factory/api/src/accounts/service.ts
+++ b/factory/api/src/accounts/service.ts
@@ -25,6 +25,7 @@ import {
type UserRecord,
} from "./model.ts";
import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts";
+import { claimOf, type IdempotencyRef } from "../idempotency.ts";
// 版の競合で読み直して書き直す回数の上限(docs/specifications/ndf-factory-accounts-and-plans.md の「常に成り立つ条件」)
const VERSION_RETRIES = 3;
@@ -161,17 +162,29 @@ export class AccountService {
}
// チームを作り、作った利用者を持ち主にして持ち主の席を 1 つ作る(受け入れ条件 9、E4)
- async createTeam(caller: Caller, name: string, seatType: SeatType): Promise {
+ // idempotencyKey があれば、同じ利用者の同じ鍵の 24 時間以内の作成を 1 件にし、2 回目は 1 回目のチームを返す(#2384 の I14)
+ async createTeam(caller: Caller, name: string, seatType: SeatType, idempotencyKey?: string): Promise {
await this.ensureUser(caller);
const at = this.stamp();
+ const ref: IdempotencyRef | undefined = idempotencyKey === undefined ? undefined : { userId: caller.userId, operationId: "createTeam", key: idempotencyKey };
+ const replayed = ref ? await this.store.getIdempotent(ref, at) : null;
+ if (replayed) return this.resolveAccount(caller, replayed);
const account = this.newAccount("team", "team", caller.userId, name, at);
const seat: SeatRecord = { accountId: account.accountId, userId: caller.userId, seatType, addedAt: at };
try {
- await this.store.createTeam(account, seat, [
- this.accountCreated(account, caller.userId, { name }),
- this.hist(account.accountId, "seat.added", at, caller.userId, null, { user_id: caller.userId, seat_type: seatType }),
- ]);
- } catch {
+ await this.store.createTeam(
+ account,
+ seat,
+ [
+ this.accountCreated(account, caller.userId, { name }),
+ this.hist(account.accountId, "seat.added", at, caller.userId, null, { user_id: caller.userId, seat_type: seatType }),
+ ],
+ ref && claimOf(ref, account.accountId, at),
+ );
+ } catch (err) {
+ // 同じ鍵の作成が同時に走り、先に書いた方が勝った
+ const won = ref && err instanceof ConditionFailedError ? await this.store.getIdempotent(ref, at) : null;
+ if (won) return this.resolveAccount(caller, won);
throw new AccountUnavailableError();
}
return this.context(account, caller.userId, seatType);
diff --git a/factory/api/src/accounts/store.ts b/factory/api/src/accounts/store.ts
index d32b771e1..30e626b18 100644
--- a/factory/api/src/accounts/store.ts
+++ b/factory/api/src/accounts/store.ts
@@ -8,6 +8,7 @@ import {
type TransactWriteCommandInput,
} from "@aws-sdk/lib-dynamodb";
import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts";
+import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts";
// 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える
export class ConditionFailedError extends Error {
@@ -33,7 +34,10 @@ export interface AccountStore {
// 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、
// 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う
listMemberships(userId: string): Promise;
- createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]): Promise;
+ // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書く。期限の中の同じ鍵があれば ConditionFailedError(#2384 の I14)
+ createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim): Promise;
+ // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う
+ getIdempotent(ref: IdempotencyRef, now: string): Promise;
// 席が無いときだけ足す(I15)
addSeat(seat: SeatRecord, history: HistoryRecord[]): Promise;
// 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ変える
@@ -232,14 +236,32 @@ export class DynamoAccountStore implements AccountStore {
return out;
}
- async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]) {
+ async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim) {
await this.transact([
this.put(accountItem(account), NOT_EXISTS),
this.put(seatItem(ownerSeat), NOT_EXISTS),
...history.map((h) => this.put(historyItem(h))),
+ ...(idempotency
+ ? [
+ {
+ Put: {
+ TableName: this.tableName,
+ Item: { pk: idempotencyPk(idempotency), sk: "IDEMP", createdId: idempotency.createdId, createdAt: idempotency.createdAt, ttl: idempotency.ttl },
+ ConditionExpression: "attribute_not_exists(pk) OR #ttl < :now",
+ ExpressionAttributeNames: { "#ttl": "ttl" },
+ ExpressionAttributeValues: { ":now": epochSeconds(idempotency.createdAt) },
+ },
+ },
+ ]
+ : []),
]);
}
+ async getIdempotent(ref: IdempotencyRef, now: string) {
+ const item = await this.get({ pk: idempotencyPk(ref), sk: "IDEMP" });
+ return liveCreatedId(item && { createdId: String(item.createdId), ttl: Number(item.ttl) }, now);
+ }
+
async addSeat(seat: SeatRecord, history: HistoryRecord[]) {
await this.transact([this.put(seatItem(seat), NOT_EXISTS), ...history.map((h) => this.put(historyItem(h)))]);
}
diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts
index ce4849191..297f218c1 100644
--- a/factory/api/src/auth/resolve.ts
+++ b/factory/api/src/auth/resolve.ts
@@ -8,7 +8,16 @@ export interface AuthDeps {
accounts: AccountService;
}
-export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps } };
+// 操作の登録の層が、操作の記録へ渡すために置く値(#2384)。対象の ID は許可した鍵の値だけ
+export interface OperationTrace {
+ operationId: string;
+ accountId: string | null;
+ targetIds: string[];
+ // 失敗した要素の誤りの code(重ねない)
+ reasons: string[];
+}
+
+export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps; operation?: OperationTrace } };
// 経路の前に token を毎回検証し、利用者を文脈に置く中間処理(E3)。
// #2171・#2186・#2174 の経路も同じ中間処理を通す
diff --git a/factory/api/src/auth/verify.test.ts b/factory/api/src/auth/verify.test.ts
index c1d7cb6a8..41fd4293b 100644
--- a/factory/api/src/auth/verify.test.ts
+++ b/factory/api/src/auth/verify.test.ts
@@ -37,7 +37,7 @@ describe("token の検証(受け入れ条件 4)", () => {
it("正しい token は通り、IdP を username の頭から決める", async () => {
const res = await api.call("GET", "/me", { user: "u-2", idp: "GitHub" });
expect(res.status).toBe(200);
- expect(await res.json()).toMatchObject({ user_id: "u-2", idp: "github" });
+ expect((await res.json())[0]).toMatchObject({ user_id: "u-2", idp: "github" });
});
it("ログには利用者の ID・経路・状態コードだけを出し、token を出さない", async () => {
diff --git a/factory/api/src/handlers/api.test.ts b/factory/api/src/handlers/api.test.ts
index b313eb9be..6c7bdca01 100644
--- a/factory/api/src/handlers/api.test.ts
+++ b/factory/api/src/handlers/api.test.ts
@@ -1,14 +1,15 @@
import { beforeEach, describe, expect, it } from "vitest";
-import { createTestApi, type TestApi } from "../testing/app.ts";
+import { createTestApi, firstResult, type TestApi } from "../testing/app.ts";
import { createApiApp } from "./api.ts";
let api: TestApi;
+let keys = 0;
beforeEach(async () => {
api = await createTestApi();
});
async function me(user: string) {
- return (await (await api.call("GET", "/me", { user })).json()) as {
+ return (await (await api.op("getMe", { user })).json())[0] as {
user_id: string;
personal_account_id: string;
accounts: { account_id: string; kind: string; plan: string; role: string; seat_type: string | null; execution_mode: string }[];
@@ -17,14 +18,20 @@ async function me(user: string) {
async function team(owner: string, seat_type = "standard") {
await me(owner);
- const res = await api.call("POST", "/teams", { user: owner, body: { name: "チーム", seat_type } });
- expect(res.status).toBe(201);
- return (await res.json()) as { account_id: string; seats: { user_id: string; seat_type: string }[] };
+ const { status, body } = await firstResult(await api.op("createTeam", { user: owner, items: [{ name: "チーム", seat_type, idempotency_key: `k-${++keys}` }] }));
+ expect(status).toBe(200);
+ return body as { account_id: string; seats: { user_id: string; seat_type: string }[] };
}
const seatsOf = (accountId: string) => api.store.listSeats(accountId);
+const addSeat = (user: string, accountId: string, user_id: string, seat_type: string) =>
+ api.op("addSeat", { user, accountId, items: [{ user_id, seat_type }] }).then(firstResult);
+const changeSeat = (user: string, accountId: string, user_id: string, seat_type: string) =>
+ api.op("changeSeat", { user, accountId, items: [{ user_id, seat_type }] }).then(firstResult);
+const removeSeat = (user: string, accountId: string, user_id: string) => api.op("removeSeat", { user, accountId, query: { user_id: [user_id] } }).then(firstResult);
+const getAccount = (user: string, id: string) => api.op("getAccount", { user, query: { account_id: [id] } }).then(firstResult);
-describe("GET /me(E2、受け入れ条件 12・18、I24)", () => {
+describe("getMe(E2、受け入れ条件 12・18、I24)", () => {
it("初回は Free・local の個人のアカウントを 1 つ作る", async () => {
const body = await me("u-1");
expect(body.accounts).toEqual([
@@ -43,27 +50,26 @@ describe("GET /me(E2、受け入れ条件 12・18、I24)", () => {
api.store.beforeWrite = () => {
throw new Error("down");
};
- const res = await api.call("GET", "/me", { user: "u-1" });
+ const res = await api.op("getMe", { user: "u-1" });
expect(res.status).toBe(503);
expect((await res.json()).error.code).toBe("account_unavailable");
api.store.beforeWrite = null;
- expect((await api.call("GET", "/me", { user: "u-1" })).status).toBe(200);
+ expect((await api.op("getMe", { user: "u-1" })).status).toBe(200);
});
it("席を持つチームが一覧に出る", async () => {
const t = await team("owner");
await me("u-1");
- await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "premium" } });
+ await addSeat("owner", t.account_id, "u-1", "premium");
const body = await me("u-1");
expect(body.accounts[1]).toMatchObject({ account_id: t.account_id, kind: "team", plan: "team", role: "member", seat_type: "premium" });
});
});
-describe("GET /accounts/{account_id}(受け入れ条件 5・6・8・18)", () => {
+describe("getAccount(受け入れ条件 5・6・8・18)", () => {
it("個人のアカウントは上限と形態を返し、席を返さない", async () => {
const { personal_account_id } = await me("u-1");
- const res = await api.call("GET", `/accounts/${personal_account_id}`, { user: "u-1" });
- expect(await res.json()).toEqual({
+ expect((await getAccount("u-1", personal_account_id)).body).toEqual({
account_id: personal_account_id,
kind: "personal",
name: null,
@@ -78,11 +84,9 @@ describe("GET /accounts/{account_id}(受け入れ条件 5・6・8・18)", ()
it("チームは席の種類で上限が決まり、managed のときだけ月の token を返す", async () => {
const t = await team("owner", "premium");
- let res = await api.call("GET", `/accounts/${t.account_id}`, { user: "owner" });
- expect((await res.json()).limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: null });
- await api.call("PUT", `/accounts/${t.account_id}/execution-mode`, { user: "owner", body: { execution_mode: "managed" } });
- res = await api.call("GET", `/accounts/${t.account_id}`, { user: "owner" });
- const body = await res.json();
+ expect((await getAccount("owner", t.account_id)).body.limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: null });
+ await api.op("setExecutionMode", { user: "owner", items: [{ account_id: t.account_id, execution_mode: "managed" }] });
+ const { body } = await getAccount("owner", t.account_id);
expect(body.limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: 125_000_000 });
expect(body.seats).toEqual([{ user_id: "owner", idp: "google", seat_type: "premium", added_at: expect.any(String) }]);
});
@@ -96,16 +100,17 @@ describe("他人のアカウント(受け入れ条件 13、I18)", () => {
const targets = [other.personal_account_id, t.account_id, "00000000-0000-4000-8000-999999999999"];
const bodies = new Set();
for (const id of targets) {
- for (const [method, path, body] of [
- ["GET", `/accounts/${id}`, undefined],
- ["PUT", `/accounts/${id}/execution-mode`, { execution_mode: "byok" }],
- ["POST", `/accounts/${id}/seats`, { user_id: "a", seat_type: "standard" }],
- ["PATCH", `/accounts/${id}/seats/owner`, { seat_type: "premium" }],
- ["DELETE", `/accounts/${id}/seats/owner`, undefined],
+ // アカウントそのものの操作は要素の forbidden、アカウントに属するものの操作は要求全体の 403。本文は同じ
+ for (const [name, call] of [
+ ["getAccount", () => getAccount("a", id)],
+ ["setExecutionMode", () => api.op("setExecutionMode", { user: "a", items: [{ account_id: id, execution_mode: "byok" }] }).then(firstResult)],
+ ["addSeat", () => addSeat("a", id, "a", "standard")],
+ ["changeSeat", () => changeSeat("a", id, "owner", "premium")],
+ ["removeSeat", () => removeSeat("a", id, "owner")],
] as const) {
- const res = await api.call(method, path, { user: "a", body });
- expect(res.status, `${method} ${path}`).toBe(403);
- bodies.add(await res.text());
+ const { status, body } = await call();
+ expect(status, name).toBe(403);
+ bodies.add(JSON.stringify(body));
}
}
expect(bodies.size).toBe(1);
@@ -120,25 +125,31 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", ()
expect(t.seats).toEqual([expect.objectContaining({ user_id: "owner", seat_type: "premium" })]);
});
+ it("同じ冪等の鍵の 2 回目は 1 回目のチームを返し、チームは 1 つだけ(#2384 の I14)", async () => {
+ await me("owner");
+ const create = () => api.op("createTeam", { user: "owner", items: [{ name: "チーム", seat_type: "standard", idempotency_key: "same" }] }).then(firstResult);
+ const first = await create();
+ const second = await create();
+ expect(second.body.account_id).toBe(first.body.account_id);
+ expect([...api.store.accounts.values()].filter((a) => a.kind === "team")).toHaveLength(1);
+ });
+
it.each([
[{ name: "", seat_type: "standard" }],
[{ name: "あ".repeat(65), seat_type: "standard" }],
[{ name: "x", seat_type: "gold" }],
])("名前か席の種類が違えば 400: %j", async (body) => {
await me("owner");
- expect((await api.call("POST", "/teams", { user: "owner", body })).status).toBe(400);
+ expect((await firstResult(await api.op("createTeam", { user: "owner", items: [{ ...body, idempotency_key: "k-400" }] }))).status).toBe(400);
});
it("持ち主は席を足し、種類を変え、外せる。そのたびに履歴が 1 つ増える", async () => {
const t = await team("owner");
await me("u-1");
const before = api.store.history.length;
- let res = await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } });
- expect(res.status).toBe(201);
- res = await api.call("PATCH", `/accounts/${t.account_id}/seats/u-1`, { user: "owner", body: { seat_type: "premium" } });
- expect(await res.json()).toMatchObject({ user_id: "u-1", seat_type: "premium" });
- res = await api.call("DELETE", `/accounts/${t.account_id}/seats/u-1`, { user: "owner" });
- expect(res.status).toBe(204);
+ expect((await addSeat("owner", t.account_id, "u-1", "standard")).status).toBe(200);
+ expect((await changeSeat("owner", t.account_id, "u-1", "premium")).body).toMatchObject({ user_id: "u-1", seat_type: "premium" });
+ expect(await removeSeat("owner", t.account_id, "u-1")).toEqual({ status: 200, body: { removed: true } });
expect(api.store.history.slice(before).map((h) => h.type)).toEqual(["seat.added", "seat.changed", "seat.removed"]);
expect(await seatsOf(t.account_id)).toHaveLength(1);
});
@@ -147,16 +158,16 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", ()
const t = await team("owner");
await me("member");
await me("u-2");
- await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "member", seat_type: "standard" } });
+ await addSeat("owner", t.account_id, "member", "standard");
const seats = await seatsOf(t.account_id);
const before = api.store.history.length;
- for (const [method, path, body] of [
- ["POST", `/accounts/${t.account_id}/seats`, { user_id: "u-2", seat_type: "standard" }],
- ["PATCH", `/accounts/${t.account_id}/seats/owner`, { seat_type: "premium" }],
- ["DELETE", `/accounts/${t.account_id}/seats/owner`, undefined],
- ["PATCH", `/accounts/${t.account_id}/seats/nobody`, { seat_type: "premium" }],
- ] as const) {
- expect((await api.call(method, path, { user: "member", body })).status).toBe(403);
+ for (const result of [
+ await addSeat("member", t.account_id, "u-2", "standard"),
+ await changeSeat("member", t.account_id, "owner", "premium"),
+ await removeSeat("member", t.account_id, "owner"),
+ await changeSeat("member", t.account_id, "nobody", "premium"),
+ ]) {
+ expect(result.status).toBe(403);
}
expect(await seatsOf(t.account_id)).toEqual(seats);
expect(api.store.history.length).toBe(before);
@@ -165,20 +176,20 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", ()
it("未ログイン → 422、既に席がある → 409、持ち主の席を外す → 409。席も履歴も変わらない", async () => {
const t = await team("owner");
await me("u-1");
- await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } });
+ await addSeat("owner", t.account_id, "u-1", "standard");
const seats = await seatsOf(t.account_id);
const before = api.store.history.length;
const cases = [
- ["POST", `/accounts/${t.account_id}/seats`, { user_id: "never", seat_type: "standard" }, 422, "user_not_found"],
- ["POST", `/accounts/${t.account_id}/seats`, { user_id: "u-1", seat_type: "premium" }, 409, "seat_exists"],
- ["DELETE", `/accounts/${t.account_id}/seats/owner`, undefined, 409, "owner_seat_protected"],
- ["PATCH", `/accounts/${t.account_id}/seats/nobody`, { seat_type: "premium" }, 404, "seat_not_found"],
- ["DELETE", `/accounts/${t.account_id}/seats/nobody`, undefined, 404, "seat_not_found"],
+ [() => addSeat("owner", t.account_id, "never", "standard"), 422, "user_not_found"],
+ [() => addSeat("owner", t.account_id, "u-1", "premium"), 409, "seat_exists"],
+ [() => removeSeat("owner", t.account_id, "owner"), 409, "owner_seat_protected"],
+ [() => changeSeat("owner", t.account_id, "nobody", "premium"), 404, "seat_not_found"],
+ [() => removeSeat("owner", t.account_id, "nobody"), 404, "seat_not_found"],
] as const;
- for (const [method, path, body, status, code] of cases) {
- const res = await api.call(method, path, { user: "owner", body });
+ for (const [call, status, code] of cases) {
+ const res = await call();
expect(res.status, code).toBe(status);
- expect((await res.json()).error).toEqual({ code, message: expect.any(String) });
+ expect(res.body.error).toEqual({ code, message: expect.any(String) });
}
expect(await seatsOf(t.account_id)).toEqual(seats);
expect(api.store.history.length).toBe(before);
@@ -187,9 +198,9 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", ()
it("個人のアカウントへの席の操作は 409 not_a_team", async () => {
const { personal_account_id } = await me("u-1");
await me("u-2");
- const res = await api.call("POST", `/accounts/${personal_account_id}/seats`, { user: "u-1", body: { user_id: "u-2", seat_type: "standard" } });
+ const res = await addSeat("u-1", personal_account_id, "u-2", "standard");
expect(res.status).toBe(409);
- expect((await res.json()).error.code).toBe("not_a_team");
+ expect(res.body.error.code).toBe("not_a_team");
});
it("書き込みが失敗したら席も履歴も増えない", async () => {
@@ -199,7 +210,7 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", ()
api.store.beforeWrite = () => {
throw new Error("down");
};
- const res = await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } });
+ const res = await addSeat("owner", t.account_id, "u-1", "standard");
expect(res.status).toBe(503);
expect(api.store.history.length).toBe(before);
expect(await seatsOf(t.account_id)).toHaveLength(1);
@@ -208,14 +219,14 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", ()
describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => {
const put = (user: string, id: string, execution_mode: unknown) =>
- api.call("PUT", `/accounts/${id}/execution-mode`, { user, body: { execution_mode } });
+ api.op("setExecutionMode", { user, items: [{ account_id: id, execution_mode }] }).then(firstResult);
it("持ち主は形態を変えられ、履歴が 1 つ増える。同じ値なら増えない", async () => {
const { personal_account_id: id } = await me("u-1");
const before = api.store.history.length;
const res = await put("u-1", id, "byok");
expect(res.status).toBe(200);
- expect((await res.json()).execution_mode).toBe("byok");
+ expect(res.body.execution_mode).toBe("byok");
expect((await put("u-1", id, "byok")).status).toBe(200);
expect(api.store.history.slice(before).map((h) => [h.type, h.before, h.after])).toEqual([["execution_mode.changed", "local", "byok"]]);
});
@@ -225,7 +236,7 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => {
const before = api.store.history.length;
const res = await put("u-1", id, "managed");
expect(res.status).toBe(409);
- expect((await res.json()).error).toEqual({
+ expect(res.body.error).toEqual({
code: "managed_not_allowed_on_free",
message: "Free の料金プランでは managed(サービスの Bedrock)を選べません",
});
@@ -236,16 +247,16 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => {
it("Pro なら managed を選べる", async () => {
const { personal_account_id: id } = await me("u-1");
await api.service.setPlan(id, "pro");
- expect((await (await put("u-1", id, "managed")).json()).limits).toEqual({ concurrent_sprint_runs: 3, monthly_tokens: 20_000_000 });
+ expect((await put("u-1", id, "managed")).body.limits).toEqual({ concurrent_sprint_runs: 3, monthly_tokens: 20_000_000 });
});
it("チームの持ち主でない席の利用者は 403 で、形態は変わらない", async () => {
const t = await team("owner");
await me("member");
- await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "member", seat_type: "standard" } });
+ await addSeat("owner", t.account_id, "member", "standard");
const res = await put("member", t.account_id, "byok");
expect(res.status).toBe(403);
- expect(await res.json()).toEqual({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } });
+ expect(res.body).toEqual({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } });
expect(api.store.accounts.get(t.account_id)?.executionMode).toBe("local");
});
diff --git a/factory/api/src/idempotency.ts b/factory/api/src/idempotency.ts
new file mode 100644
index 000000000..9fa7857fe
--- /dev/null
+++ b/factory/api/src/idempotency.ts
@@ -0,0 +1,41 @@
+// 作成の冪等の鍵(#2384 の決定 4・不変条件 I14)。新しい識別子を作る作成で、同じ利用者・同じ操作・同じ鍵の
+// 24 時間以内の作成を 1 件にする。鍵の行は作るものと同じ表へ同じトランザクションで書く
+import { InvalidRequestError } from "./errors.ts";
+
+export const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60;
+const KEY_PATTERN = /^[0-9A-Za-z-]{1,64}$/;
+
+// 鍵の行を引くための 3 つの値
+export interface IdempotencyRef {
+ userId: string;
+ operationId: string;
+ key: string;
+}
+
+// 鍵の行に書く値。ttl は作った日時の 24 時間後(UNIX 秒)
+export interface IdempotencyClaim extends IdempotencyRef {
+ createdId: string;
+ createdAt: string;
+ ttl: number;
+}
+
+export function parseIdempotencyKey(raw: unknown): string {
+ if (typeof raw !== "string" || !KEY_PATTERN.test(raw)) {
+ throw new InvalidRequestError("作成の冪等の鍵は 1〜64 字の英数字とハイフンです", "invalid_idempotency_key");
+ }
+ return raw;
+}
+
+export const idempotencyPk = (ref: IdempotencyRef) => `IDEMP#${ref.userId}#${ref.operationId}#${ref.key}`;
+
+export const epochSeconds = (iso: string) => Math.floor(Date.parse(iso) / 1000);
+
+export function claimOf(ref: IdempotencyRef, createdId: string, createdAt: string): IdempotencyClaim {
+ return { ...ref, createdId, createdAt, ttl: epochSeconds(createdAt) + IDEMPOTENCY_TTL_SECONDS };
+}
+
+// 期限は TTL の削除に頼らず、読むたびに今の時刻と比べる(DynamoDB の TTL は期限を過ぎた行をすぐには消さない)
+export function liveCreatedId(row: { createdId: string; ttl: number } | null, nowIso: string): string | null {
+ if (!row) return null;
+ return row.ttl > epochSeconds(nowIso) ? row.createdId : null;
+}
diff --git a/factory/api/src/routes/accounts.ts b/factory/api/src/routes/accounts.ts
index d08c50839..30b594bb5 100644
--- a/factory/api/src/routes/accounts.ts
+++ b/factory/api/src/routes/accounts.ts
@@ -1,57 +1,69 @@
-import type { Hono } from "hono";
+import type { Context, Hono } from "hono";
import { ForbiddenError, InvalidRequestError } from "../errors.ts";
-import { isExecutionMode, isSeatType } from "../accounts/model.ts";
+import { isExecutionMode, isSeatType, type AccountContext } from "../accounts/model.ts";
import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts";
-import { readJson } from "./body.ts";
+import { registerOperation } from "./operation.ts";
import { accountDetail, seatView } from "./views.ts";
const SEAT_TYPE_MESSAGE = "席の種類は standard か premium です";
+function userIdOf(raw: unknown): string {
+ if (typeof raw !== "string" || raw === "" || raw.length > 128) throw new InvalidRequestError("利用者の ID の形ではありません");
+ return raw;
+}
+
+// アカウントそのものの操作は、要素がアカウントのため要素ごとに解決する(#2384 の決定 2)。解決できない要素は forbidden
+function accountOf(c: Context, raw: unknown): Promise {
+ if (typeof raw !== "string" || raw === "") throw new ForbiddenError();
+ return resolveFromRequest(c, raw);
+}
+
export function registerAccounts(app: Hono): void {
- app.get("/accounts/:accountId", async (c) => {
- const ctx = await resolveFromRequest(c, c.req.param("accountId"));
- const accounts = c.get("auth").accounts;
- const account = await accounts.getAccount(ctx.accountId);
- if (!account) throw new ForbiddenError();
- const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null;
- return c.json(accountDetail(ctx, account, seats));
+ registerOperation(app, "getAccount", {
+ run: async (e, _ctx, _s, c) => {
+ const ctx = await accountOf(c, e.account_id);
+ const accounts = c.get("auth").accounts;
+ const account = await accounts.getAccount(ctx.accountId);
+ if (!account) throw new ForbiddenError();
+ const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null;
+ return accountDetail(ctx, account, seats);
+ },
});
- app.put("/accounts/:accountId/execution-mode", async (c) => {
- const ctx = await resolveFromRequest(c, c.req.param("accountId"));
- const body = await readJson(c);
- if (!isExecutionMode(body.execution_mode)) {
- throw new InvalidRequestError("実行の形態は local・byok・managed のどれかです");
- }
- const accounts = c.get("auth").accounts;
- const account = await accounts.setExecutionMode(ctx, body.execution_mode);
- const next = { ...ctx, executionMode: account.executionMode, plan: account.plan };
- const seats = next.kind === "team" ? await accounts.listSeats(next) : null;
- return c.json(accountDetail(next, account, seats));
+ registerOperation(app, "setExecutionMode", {
+ run: async (e, _ctx, _s, c) => {
+ const ctx = await accountOf(c, e.account_id);
+ if (!isExecutionMode(e.execution_mode)) {
+ throw new InvalidRequestError("実行の形態は local・byok・managed のどれかです");
+ }
+ const accounts = c.get("auth").accounts;
+ const account = await accounts.setExecutionMode(ctx, e.execution_mode);
+ const next = { ...ctx, executionMode: account.executionMode, plan: account.plan };
+ const seats = next.kind === "team" ? await accounts.listSeats(next) : null;
+ return accountDetail(next, account, seats);
+ },
});
- app.post("/accounts/:accountId/seats", async (c) => {
- const ctx = await resolveFromRequest(c, c.req.param("accountId"));
- const body = await readJson(c);
- if (typeof body.user_id !== "string" || body.user_id === "" || body.user_id.length > 128) {
- throw new InvalidRequestError("利用者の ID の形ではありません");
- }
- if (!isSeatType(body.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE);
- const seat = await c.get("auth").accounts.addSeat(ctx, body.user_id, body.seat_type);
- return c.json(seatView(seat), 201);
+ registerOperation(app, "addSeat", {
+ run: async (e, ctx, _s, c) => {
+ const userId = userIdOf(e.user_id);
+ if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE);
+ return seatView(await c.get("auth").accounts.addSeat(ctx!, userId, e.seat_type));
+ },
});
- app.patch("/accounts/:accountId/seats/:userId", async (c) => {
- const ctx = await resolveFromRequest(c, c.req.param("accountId"));
- const body = await readJson(c);
- if (!isSeatType(body.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE);
- const seat = await c.get("auth").accounts.changeSeat(ctx, c.req.param("userId"), body.seat_type);
- return c.json(seatView(seat));
+ registerOperation(app, "changeSeat", {
+ run: async (e, ctx, _s, c) => {
+ const userId = userIdOf(e.user_id);
+ if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE);
+ return seatView(await c.get("auth").accounts.changeSeat(ctx!, userId, e.seat_type));
+ },
});
- app.delete("/accounts/:accountId/seats/:userId", async (c) => {
- const ctx = await resolveFromRequest(c, c.req.param("accountId"));
- await c.get("auth").accounts.removeSeat(ctx, c.req.param("userId"));
- return c.body(null, 204);
+ registerOperation(app, "removeSeat", {
+ run: async (e, ctx, _s, c) => {
+ await c.get("auth").accounts.removeSeat(ctx!, userIdOf(e.user_id));
+ return { removed: true };
+ },
});
}
diff --git a/factory/api/src/routes/body.ts b/factory/api/src/routes/body.ts
deleted file mode 100644
index a32bd9983..000000000
--- a/factory/api/src/routes/body.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { Context } from "hono";
-import { InvalidRequestError } from "../errors.ts";
-
-export async function readJson(c: Context): Promise> {
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- throw new InvalidRequestError();
- }
- if (typeof body !== "object" || body === null || Array.isArray(body)) throw new InvalidRequestError();
- return body as Record;
-}
diff --git a/factory/api/src/routes/elements.ts b/factory/api/src/routes/elements.ts
new file mode 100644
index 000000000..eae8fcb43
--- /dev/null
+++ b/factory/api/src/routes/elements.ts
@@ -0,0 +1,35 @@
+// 要素ごとの実行(#2384 の決定 3・不変条件 I13)。要素を並べる数 10 で順にユースケースへ渡し、
+// ApiError を要素の結果に包む。応答は入力と同じ順・同じ数で、失敗の要素はその要素のデータを持たない
+import { ApiError } from "../errors.ts";
+
+// 要素の読み出しを並べる数の上限(一覧の行の読み出しと同じ値)
+export const ELEMENT_CONCURRENCY = 10;
+
+export type ElementResult =
+ | { ok: true; value: unknown }
+ | { ok: false; status: number; error: { code: string; message: string; details?: Record } };
+
+export async function mapLimit(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise {
+ const out = new Array(items.length);
+ let next = 0;
+ const worker = async () => {
+ while (next < items.length) {
+ const i = next++;
+ out[i] = await fn(items[i]!, i);
+ }
+ };
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
+ return out;
+}
+
+// ApiError でない失敗(保存の不調など)はそのまま投げ、要求全体の 503 にする
+export function runElements(elements: E[], fn: (element: E) => Promise): Promise {
+ return mapLimit(elements, ELEMENT_CONCURRENCY, async (element) => {
+ try {
+ return { ok: true as const, value: await fn(element) };
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err;
+ return { ok: false as const, status: err.status, error: err.body().error };
+ }
+ });
+}
diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts
index 60de82223..7655d639a 100644
--- a/factory/api/src/routes/factory.test.ts
+++ b/factory/api/src/routes/factory.test.ts
@@ -9,7 +9,8 @@ import { CANDIDATE_FALLBACK_NOTE, type CandidateModel } from "../runs/candidates
import { LoopControl } from "../runs/loops.ts";
import { createRunsHarness, type RunsHarness } from "../testing/runs.ts";
import { createTokenKit, type TokenKit } from "../testing/tokens.ts";
-import type { TestResponse } from "../testing/app.ts";
+import type { OperationId } from "@ndf-factory/contracts/operations";
+import { firstResult, opRequest, type OpOptions, type TestResponse } from "../testing/app.ts";
const API_KEY = "sk-ant-test-SECRET-VALUE-0001";
@@ -52,8 +53,17 @@ async function call(method: string, path: string, user: string, body?: unknown):
return res;
}
+// 操作を呼び、要素 1 個の結果を状態と本文で読む(#2384)
+async function op(operationId: OperationId, user: string, opts: Omit = {}): Promise<{ status: number; body: any }> {
+ const { method, path, body } = opRequest(operationId, opts);
+ return firstResult(await call(method, path, user, body));
+}
+
+let keys = 0;
+const idem = () => `k-${++keys}`;
+
async function personal(user: string): Promise {
- return ((await (await call("GET", "/me", user)).json()) as { personal_account_id: string }).personal_account_id;
+ return (await op("getMe", user)).body.personal_account_id;
}
beforeEach(async () => {
@@ -88,97 +98,145 @@ beforeEach(async () => {
});
async function candidates(accountId: string, user = "u-1") {
- const url = (await (await call("GET", `/accounts/${accountId}/github/authorize-url`, user)).json()).url as string;
+ const url = (await op("getAuthorizeUrl", user, { accountId })).body.url as string;
const state = new URL(url).searchParams.get("state");
- return call("POST", `/accounts/${accountId}/github/candidates`, user, { code: "code-1", state });
+ return op("postRepositoryCandidates", user, { accountId, items: [{ code: "code-1", state }] });
}
async function register(accountId: string, repositoryId = 101) {
- const { proof } = await (await candidates(accountId)).json();
- return call("POST", `/accounts/${accountId}/repositories`, "u-1", { proof, repository_id: repositoryId });
+ const { proof } = (await candidates(accountId)).body;
+ return op("registerRepository", "u-1", { accountId, items: [{ proof, repository_id: repositoryId }] });
}
describe("取り込み(受け入れ条件 1〜5、I32・I33・I36)", () => {
it("導入の案内と、利用者の届くリポジトリだけの候補が出る(AC1)", async () => {
const acc = await personal("u-1");
- expect((await (await call("GET", `/accounts/${acc}/github/install-url`, "u-1")).json()).url).toContain("/installations/new");
- const body = await (await candidates(acc)).json();
+ expect((await op("getInstallUrl", "u-1", { accountId: acc })).body.url).toContain("/installations/new");
+ const { body } = await candidates(acc);
expect(body.candidates.map((c: { repository_id: number }) => c.repository_id).sort()).toEqual([101, 102]);
});
it("state の利用者が違えば 400(決定 12)", async () => {
const acc = await personal("u-1");
- const res = await call("POST", `/accounts/${acc}/github/candidates`, "u-1", { code: "code-1", state: "x" });
+ expect((await op("postRepositoryCandidates", "u-1", { accountId: acc, items: [{ code: "code-1", state: "x" }] })).status).toBe(400);
+ });
+
+ it("鍵を持たない操作に 2 個の要素を送ると、どれも処理せずに 400 duplicate_key", async () => {
+ const acc = await personal("u-1");
+ const res = await call("POST", `/accounts/${acc}/github/candidates`, "u-1", [{ code: "code-1", state: "x" }, { code: "code-1", state: "y" }]);
expect(res.status).toBe(400);
+ expect((await res.json()).error.code).toBe("duplicate_key");
});
it("証明に無い ID・導入が届かない登録は 403 で行が増えない(AC2・I33)", async () => {
const acc = await personal("u-1");
- const { proof } = await (await candidates(acc)).json();
- expect((await call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: 201 })).status).toBe(403);
+ const { proof } = (await candidates(acc)).body;
+ expect((await op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: 201 }] })).status).toBe(403);
h.github.installations.get(9)!.delete(102);
- expect((await call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: 102 })).status).toBe(403);
+ expect((await op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: 102 }] })).status).toBe(403);
expect(h.table.registrations.size).toBe(0);
});
it("同じリポジトリの 2 回目と同時の 2 本は 409 で 1 行(AC3・I32)", async () => {
const acc = await personal("u-1");
- expect((await register(acc)).status).toBe(201);
+ expect((await register(acc)).status).toBe(200);
expect((await register(acc)).status).toBe(409);
- const { proof } = await (await candidates(acc)).json();
- const both = await Promise.all([102, 102].map((id) => call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: id })));
- expect(both.map((r) => r.status).sort()).toEqual([201, 409]);
+ const { proof } = (await candidates(acc)).body;
+ const both = await Promise.all([102, 102].map((id) => op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: id }] })));
+ expect(both.map((r) => r.status).sort()).toEqual([200, 409]);
expect(h.table.registrations.size).toBe(2);
});
- it("終わりの状態でない実行があると外せず 409、終われば 204(AC4・I36)", async () => {
+ it("終わりの状態でない実行があると外せず 409、終われば外せる(AC4・I36)", async () => {
const acc = await personal("u-1");
await register(acc);
h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }]);
- const started = await call("POST", `/accounts/${acc}/sprint-runs`, "u-1", { repository_id: 101, issues: [1] });
- expect(started.status).toBe(201);
- const { sprint_run_id } = await started.json();
+ const started = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: idem() }] });
+ expect(started.status).toBe(200);
+ const { sprint_run_id } = started.body;
const list = await (await call("GET", `/accounts/${acc}/repositories`, "u-1")).json();
- expect(list.repositories[0].active_sprint_runs).toBe(1);
- expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(409);
- expect((await call("POST", `/accounts/${acc}/sprint-runs/${sprint_run_id}/cancel`, "u-1")).status).toBe(200);
- expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(204);
- expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(404);
+ expect(list[0].active_sprint_runs).toBe(1);
+ const remove = () => op("removeRepository", "u-1", { accountId: acc, query: { repository_id: [101] } });
+ expect((await remove()).status).toBe(409);
+ expect((await op("cancelSprintRun", "u-1", { accountId: acc, items: [{ sprint_run_id }] })).status).toBe(200);
+ expect(await remove()).toEqual({ status: 200, body: { removed: true } });
+ expect((await remove()).status).toBe(404);
});
it("他人のアカウントの一覧・登録・外しは #2170 と同じ 403 の本文(AC5)", async () => {
const acc = await personal("u-1");
await personal("u-2");
const forbidden = { error: { code: "forbidden", message: "このアカウントを見る権限がありません" } };
- for (const [method, path, body] of [
- ["GET", `/accounts/${acc}/repositories`, undefined],
- ["POST", `/accounts/${acc}/repositories`, { proof: "x", repository_id: 101 }],
- ["DELETE", `/accounts/${acc}/repositories/101`, undefined],
- ] as const) {
- const res = await call(method, path, "u-2", body);
- expect(res.status).toBe(403);
- expect(await res.json()).toEqual(forbidden);
+ for (const result of [
+ await op("listRepositories", "u-2", { accountId: acc }),
+ await op("registerRepository", "u-2", { accountId: acc, items: [{ proof: "x", repository_id: 101 }] }),
+ await op("removeRepository", "u-2", { accountId: acc, query: { repository_id: [101] } }),
+ ]) {
+ expect(result).toEqual({ status: 403, body: forbidden });
}
});
});
+describe("作成の冪等の鍵(#2384 の受け入れ条件 9・I14)", () => {
+ async function ready() {
+ const acc = await personal("u-1");
+ await register(acc);
+ h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }, { number: 2, title: "b" }]);
+ return acc;
+ }
+
+ it("同じ鍵の 2 回目は 1 件だけ作り、同じ識別子を返す", async () => {
+ const acc = await ready();
+ const item = { repository_id: 101, issues: [1], idempotency_key: "same-key" };
+ const first = await op("startSprintRun", "u-1", { accountId: acc, items: [item] });
+ const second = await op("startSprintRun", "u-1", { accountId: acc, items: [item] });
+ expect(second.body.sprint_run_id).toBe(first.body.sprint_run_id);
+ expect(h.table.runs.size).toBe(1);
+ });
+
+ it("違う作成の操作へ同じ鍵を送ると、それぞれ作る", async () => {
+ const acc = await ready();
+ await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "shared" }] });
+ const loop = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "shared" }] });
+ expect(loop.status).toBe(200);
+ expect(h.table.loops.size).toBe(1);
+ expect(h.table.runs.size).toBeGreaterThanOrEqual(1);
+ });
+
+ it("鍵の行の期限(24 時間)を過ぎた同じ鍵は、行が残っていても新しく作る", async () => {
+ const acc = await ready();
+ const first = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "old" }] });
+ // 1 本目を終わらせ、同時の実行の数の上限に当たらないようにする
+ await h.table.finishRun(first.body.sprint_run_id, "done", null, h.clock.now.toISOString());
+ h.clock.now = new Date(h.clock.now.getTime() + 24 * 60 * 60 * 1000 + 1000);
+ const second = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [2], idempotency_key: "old" }] });
+ expect(second.status).toBe(200);
+ expect(second.body.sprint_run_id).not.toBe(first.body.sprint_run_id);
+ expect(h.table.idempotency.size).toBe(1);
+ });
+
+ it("鍵の形が違えば要素だけを 400 にする", async () => {
+ const acc = await ready();
+ expect((await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "空白 あり" }] })).status).toBe(400);
+ expect(h.table.runs.size).toBe(0);
+ });
+});
+
describe("キーの持ち込み(受け入れ条件 6〜8、I38)", () => {
+ const put = (acc: string, item: unknown) => op("putModelCredential", "u-1", { accountId: acc, items: [item] });
+
it("2 種類の保存・差し替え・消去ができ、応答は 4 項目だけで値と ARN を返さない(AC6・AC7)", async () => {
const acc = await personal("u-1");
- const saved = await (await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "anthropic_api_key", api_key: API_KEY })).json();
+ const saved = (await put(acc, { kind: "anthropic_api_key", api_key: API_KEY })).body;
expect(Object.keys(saved).sort()).toEqual(["external_id", "kind", "registered", "updated_at"]);
expect(saved).toMatchObject({ registered: true, kind: "anthropic_api_key" });
- const role = await call("PUT", `/accounts/${acc}/model-credential`, "u-1", {
- kind: "aws_role",
- role_arn: "arn:aws:iam::123456789012:role/ndf",
- region: "us-east-1",
- });
- expect((await role.json()).kind).toBe("aws_role");
+ const role = await put(acc, { kind: "aws_role", role_arn: "arn:aws:iam::123456789012:role/ndf", region: "us-east-1" });
+ expect(role.body.kind).toBe("aws_role");
expect(secrets.values.size).toBe(1);
expect(JSON.parse([...secrets.values.values()][0]!)).toMatchObject({ external_id: `ndf-factory:${acc}` });
- expect((await call("DELETE", `/accounts/${acc}/model-credential`, "u-1")).status).toBe(204);
+ expect(await op("deleteModelCredential", "u-1", { accountId: acc })).toEqual({ status: 200, body: { removed: true } });
expect(secrets.values.size).toBe(0);
- expect(await (await call("GET", `/accounts/${acc}/model-credential`, "u-1")).json()).toEqual({
+ expect((await op("getModelCredential", "u-1", { accountId: acc })).body).toEqual({
registered: false,
kind: null,
updated_at: null,
@@ -190,30 +248,33 @@ describe("キーの持ち込み(受け入れ条件 6〜8、I38)", () => {
});
it("外部 ID はアカウントごとに違う(AC8)", async () => {
- const a = await (await call("GET", `/accounts/${await personal("u-1")}/model-credential`, "u-1")).json();
- const b = await (await call("GET", `/accounts/${await personal("u-2")}/model-credential`, "u-2")).json();
+ const a = (await op("getModelCredential", "u-1", { accountId: await personal("u-1") })).body;
+ const b = (await op("getModelCredential", "u-2", { accountId: await personal("u-2") })).body;
expect(a.external_id).not.toBe(b.external_id);
});
it("形の違う入力は 400、置き場に書けなければ 503", async () => {
const acc = await personal("u-1");
- expect((await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "aws_role", role_arn: "x", region: "us-east-1" })).status).toBe(400);
+ expect((await put(acc, { kind: "aws_role", role_arn: "x", region: "us-east-1" })).status).toBe(400);
secrets.fail = true;
- const res = await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "anthropic_api_key", api_key: API_KEY });
+ const res = await put(acc, { kind: "anthropic_api_key", api_key: API_KEY });
expect(res.status).toBe(503);
- expect((await res.json()).error.code).toBe("credential_unavailable");
+ expect(res.body.error.code).toBe("credential_unavailable");
});
});
describe("課題の候補(受け入れ条件 9・10)", () => {
+ const ask = (acc: string, exclude?: number[]) =>
+ op("postIssueCandidates", "u-1", { accountId: acc, items: [{ repository_id: 101, ...(exclude ? { exclude } : {}) }] }).then((r) => r.body);
+
it("順位の表があれば理由つきの候補、選び直しで外した番号は出ない(AC9・AC10)", async () => {
const acc = await personal("u-1");
await register(acc);
h.github.rankings.set("org-a/repo-a", { milestone: "03 Factory", rows: [{ number: 5, title: "e" }, { number: 6, title: "f" }] });
- const res = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", {})).json();
+ const res = await ask(acc);
expect(res).toMatchObject({ source: "ranking", milestone: "03 Factory", note: null });
expect(res.candidates.every((c: { reason: string }) => c.reason !== "")).toBe(true);
- const again = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", { exclude: [5] })).json();
+ const again = await ask(acc, [5]);
expect(again.candidates.map((c: { number: number }) => c.number)).toEqual([6]);
});
@@ -222,7 +283,7 @@ describe("課題の候補(受け入れ条件 9・10)", () => {
await register(acc);
h.github.openIssues.set("org-a/repo-a", [{ number: 9, title: "i" }, { number: 8, title: "h" }]);
model.fn = () => Promise.reject(new Error("ThrottlingException"));
- const res = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", {})).json();
+ const res = await ask(acc);
expect(res.source).toBe("recent");
expect(res.note).toBe(CANDIDATE_FALLBACK_NOTE);
expect(res.candidates.map((c: { number: number }) => c.number)).toEqual([9, 8]);
@@ -230,24 +291,24 @@ describe("課題の候補(受け入れ条件 9・10)", () => {
});
describe("Factory のオーガナイザーの経路(#2439 の AC 9・11、I66)", () => {
- it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 409、承認の依頼へ答える経路は無い", async () => {
+ it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 404、承認の依頼へ答える経路は無い", async () => {
const acc = await personal("u-1");
await register(acc);
- const list = await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-1");
- expect(list.status).toBe(200);
- expect(await list.json()).toEqual({ organizer: { current_seq: null, wait_reason: null, wait_detail: null }, items: [] });
- const history = await call("GET", `/accounts/${acc}/repositories/101/releases?limit=5`, "u-1");
- expect(await history.json()).toEqual({ items: [] });
- expect((await call("GET", `/accounts/${acc}/repositories/101/releases?limit=0`, "u-1")).status).toBe(400);
- expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "reject" })).status).toBe(404);
- expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "maybe" })).status).toBe(400);
- expect([404, 405]).toContain((await call("POST", `/accounts/${acc}/repositories/101/approval-requests`, "u-1", {})).status);
+ const list = await op("listRepositoryApprovalRequests", "u-1", { accountId: acc, query: { repository_id: [101] } });
+ expect(list).toEqual({ status: 200, body: { repository_id: 101, organizer: { current_seq: null, wait_reason: null, wait_detail: null }, items: [] } });
+ const history = await call("GET", `/accounts/${acc}/releases?repository_id=101&limit=5`, "u-1");
+ expect(await history.json()).toEqual([]);
+ expect((await call("GET", `/accounts/${acc}/releases?repository_id=101&limit=0`, "u-1")).status).toBe(400);
+ const resolve = (choice: string) => op("resolveStalledRelease", "u-1", { accountId: acc, items: [{ repository_id: 101, seq: 1, choice }] });
+ expect((await resolve("reject")).status).toBe(404);
+ expect((await resolve("maybe")).status).toBe(400);
+ expect([404, 405]).toContain((await call("POST", `/accounts/${acc}/approval-requests`, "u-1", [{}])).status);
});
it("他人のアカウントのリポジトリは 403", async () => {
const acc = await personal("u-1");
await register(acc);
await personal("u-2");
- expect((await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-2")).status).toBe(403);
+ expect((await op("listRepositoryApprovalRequests", "u-2", { accountId: acc, query: { repository_id: [101] } })).status).toBe(403);
});
});
diff --git a/factory/api/src/routes/factory.ts b/factory/api/src/routes/factory.ts
index 71b41254f..758a237fd 100644
--- a/factory/api/src/routes/factory.ts
+++ b/factory/api/src/routes/factory.ts
@@ -1,7 +1,8 @@
// 画面向けの経路(#2186・#2347・#2172)。取り込み・キーの持ち込み・課題の候補・スプリントの実行・開発ループ。
-// どの経路も resolveFromRequest でアカウントを解決し、他人のアカウントは #2170 の I18 と同じ 403
-import type { Context, Hono } from "hono";
-import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts";
+// どの操作も操作の登録の層(operation.ts)で登録し、経路のアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403。
+// 要素の形は機能から起こした契約(#2384)で、要素ごとの失敗は要素の結果になる
+import type { Hono } from "hono";
+import type { AuthEnv } from "../auth/resolve.ts";
import type { CredentialService } from "../credentials/service.ts";
import { InvalidRequestError } from "../errors.ts";
import type { GitHubPort } from "../github/port.ts";
@@ -12,7 +13,8 @@ import { chooseCandidates, type CandidateModel } from "../runs/candidates.ts";
import type { RunControl } from "../runs/control.ts";
import type { LoopControl } from "../runs/loops.ts";
import type { RunsTable } from "../runs/table.ts";
-import { readJson } from "./body.ts";
+import { parseIdempotencyKey } from "../idempotency.ts";
+import { registerList, registerOperation } from "./operation.ts";
import { registerSprintViews, sprintRunList } from "./sprint-views.ts";
export interface FactoryDeps {
@@ -28,147 +30,121 @@ export interface FactoryDeps {
reader: ProgressReader;
}
-function positiveInt(raw: string | undefined): number {
- const n = Number(raw);
- if (!Number.isInteger(n) || n < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません");
- return n;
+export function positiveInt(raw: unknown, message = "リポジトリの ID の形ではありません"): number {
+ const n = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
+ if (!Number.isInteger(n) || (n as number) < 1) throw new InvalidRequestError(message);
+ return n as number;
}
-export function registerFactory(app: Hono, deps: () => FactoryDeps): void {
- const ctxOf = (c: Context) => resolveFromRequest(c, c.req.param("accountId"));
+function idOf(raw: unknown, message: string): string {
+ if (typeof raw !== "string" || raw === "") throw new InvalidRequestError(message);
+ return raw;
+}
- app.get("/accounts/:accountId/github/install-url", async (c) => {
- await ctxOf(c);
- return c.json(deps().repositories.installUrl());
- });
+const removed = { removed: true } as const;
- app.get("/accounts/:accountId/github/authorize-url", async (c) => {
- return c.json(await deps().repositories.authorizeUrl(await ctxOf(c)));
- });
+export function registerFactory(app: Hono, deps: () => FactoryDeps): void {
+ registerList(app, "getInstallUrl", async () => [deps().repositories.installUrl()]);
- app.post("/accounts/:accountId/github/candidates", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().repositories.candidates(ctx, await readJson(c)));
- });
+ registerList(app, "getAuthorizeUrl", async (_c, ctx) => [await deps().repositories.authorizeUrl(ctx!)]);
- app.get("/accounts/:accountId/repositories", async (c) => {
- return c.json(await deps().repositories.list(await ctxOf(c)));
- });
+ registerOperation(app, "postRepositoryCandidates", { run: (e, ctx) => deps().repositories.candidates(ctx!, e) });
- app.post("/accounts/:accountId/repositories", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().repositories.register(ctx, await readJson(c)), 201);
- });
+ registerList(app, "listRepositories", async (_c, ctx) => (await deps().repositories.list(ctx!)).repositories);
- app.patch("/accounts/:accountId/repositories/:repositoryId", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().repositories.declareConcurrency(ctx, positiveInt(c.req.param("repositoryId")), await readJson(c)));
- });
+ registerOperation(app, "registerRepository", { run: (e, ctx) => deps().repositories.register(ctx!, e) });
- app.delete("/accounts/:accountId/repositories/:repositoryId", async (c) => {
- const ctx = await ctxOf(c);
- await deps().repositories.unregister(ctx, positiveInt(c.req.param("repositoryId")));
- return c.body(null, 204);
+ registerOperation(app, "updateRepository", {
+ run: (e, ctx) => deps().repositories.declareConcurrency(ctx!, positiveInt(e.repository_id), e),
});
- app.get("/accounts/:accountId/model-credential", async (c) => {
- return c.json(await deps().credentials.get(await ctxOf(c)));
+ registerOperation(app, "removeRepository", {
+ run: async (e, ctx) => {
+ await deps().repositories.unregister(ctx!, positiveInt(e.repository_id));
+ return removed;
+ },
});
- app.put("/accounts/:accountId/model-credential", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().credentials.put(ctx, await readJson(c)));
- });
+ registerList(app, "getModelCredential", async (_c, ctx) => [await deps().credentials.get(ctx!)]);
- app.delete("/accounts/:accountId/model-credential", async (c) => {
- await deps().credentials.delete(await ctxOf(c));
- return c.body(null, 204);
+ registerOperation(app, "putModelCredential", { run: (e, ctx) => deps().credentials.put(ctx!, e) });
+
+ registerOperation(app, "deleteModelCredential", {
+ run: async (_e, ctx) => {
+ await deps().credentials.delete(ctx!);
+ return removed;
+ },
});
- app.post("/accounts/:accountId/repositories/:repositoryId/issue-candidates", async (c) => {
- const ctx = await ctxOf(c);
- const body = await readJson(c);
- const exclude = Array.isArray(body.exclude) ? body.exclude.filter((n): n is number => Number.isInteger(n)) : [];
- const d = deps();
- const list = await chooseCandidates({ table: d.table, github: d.github, model: d.candidateModel }, ctx, positiveInt(c.req.param("repositoryId")), exclude);
- return c.json(list);
+ registerOperation(app, "postIssueCandidates", {
+ run: (e, ctx) => {
+ const exclude = Array.isArray(e.exclude) ? e.exclude.filter((n): n is number => Number.isInteger(n)) : [];
+ const d = deps();
+ return chooseCandidates({ table: d.table, github: d.github, model: d.candidateModel }, ctx!, positiveInt(e.repository_id), exclude);
+ },
});
- app.post("/accounts/:accountId/sprint-runs", async (c) => {
- const ctx = await ctxOf(c);
- const body = await readJson(c);
- return c.json(await deps().control.startSprintRun(ctx, { repositoryId: body.repository_id, issues: body.issues }), 201);
+ // 同じ冪等の鍵の 2 回目は 1 回目の実行の識別子を返す(#2384 の I14)
+ registerOperation(app, "startSprintRun", {
+ run: (e, ctx) =>
+ deps().control.startSprintRun(ctx!, { repositoryId: e.repository_id, issues: e.issues, idempotencyKey: parseIdempotencyKey(e.idempotency_key) }),
});
- app.get("/accounts/:accountId/sprint-runs", async (c) => {
- const ctx = await ctxOf(c);
+ registerList(app, "listSprintRuns", async (c, ctx) => {
const raw = c.req.query("repository_id");
- return c.json({ sprint_runs: await sprintRunList(deps(), ctx, raw === undefined ? null : positiveInt(raw)) });
+ return sprintRunList(deps(), ctx!, raw === undefined ? null : positiveInt(raw));
});
// スプリントの詳細・工程の中身・バックログ(#2172)
registerSprintViews(app, deps);
- app.post("/accounts/:accountId/sprint-runs/:sprintRunId/cancel", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().control.cancelSprintRun(ctx, c.req.param("sprintRunId")));
+ registerOperation(app, "cancelSprintRun", {
+ run: (e, ctx) => deps().control.cancelSprintRun(ctx!, idOf(e.sprint_run_id, "実行の ID の形ではありません")),
});
// 開発ループ(openapi/loops.yaml)
- app.post("/accounts/:accountId/repositories/:repositoryId/loops", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().loops.startLoop(ctx, positiveInt(c.req.param("repositoryId")), await readJson(c)), 201);
- });
+ registerOperation(app, "startLoop", { run: (e, ctx) => deps().loops.startLoop(ctx!, positiveInt(e.repository_id), { ...e, idempotency_key: parseIdempotencyKey(e.idempotency_key) }) });
- app.get("/accounts/:accountId/repositories/:repositoryId/loops", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().loops.listLoops(ctx, positiveInt(c.req.param("repositoryId"))));
- });
+ registerList(app, "listLoops", async (c, ctx) => (await deps().loops.listLoops(ctx!, positiveInt(c.req.query("repository_id")))).loops);
- app.get("/accounts/:accountId/loops/:loopId", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().loops.getLoopDetail(ctx, c.req.param("loopId")));
- });
+ // 無いループは今の 404(loop_not_found)のまま要素の結果になる
+ registerOperation(app, "getLoop", { run: (e, ctx) => deps().loops.getLoopDetail(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) });
- app.patch("/accounts/:accountId/loops/:loopId", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().loops.updateBudget(ctx, c.req.param("loopId"), await readJson(c)));
+ registerOperation(app, "updateLoopBudget", {
+ run: (e, ctx) => deps().loops.updateBudget(ctx!, idOf(e.loop_id, "ループの ID の形ではありません"), { budget: e.budget }),
});
- app.post("/accounts/:accountId/loops/:loopId/cancel", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().loops.cancelLoop(ctx, c.req.param("loopId")));
- });
+ registerOperation(app, "cancelLoop", { run: (e, ctx) => deps().loops.cancelLoop(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) });
- app.post("/accounts/:accountId/loops/:loopId/next-lap", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().loops.requestNextLap(ctx, c.req.param("loopId")), 202);
- });
+ registerOperation(app, "startNextLap", { run: (e, ctx) => deps().loops.requestNextLap(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) });
- app.post("/accounts/:accountId/loops/:loopId/answers", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().loops.answer(ctx, c.req.param("loopId"), await readJson(c)));
+ // 人の判断待ちへの答え。人だけが確定できる(扱い human)
+ registerOperation(app, "answerLoopPending", {
+ run: async (e, ctx) => {
+ const loopId = idOf(e.loop_id, "ループの ID の形ではありません");
+ const { pending } = await deps().loops.answer(ctx!, loopId, { issue: e.issue, decision: e.decision, pr: e.pr });
+ return { loop_id: loopId, pending };
+ },
});
- // Factory のオーガナイザー(openapi/organizer.yaml。#2439)。一覧と履歴は読むだけで、承認を確定する経路は持たない(I66)
- app.get("/accounts/:accountId/repositories/:repositoryId/approval-requests", async (c) => {
- const ctx = await ctxOf(c);
- return c.json(await deps().organizer.list(ctx, positiveInt(c.req.param("repositoryId"))));
+ // Factory のオーガナイザー(openapi/organizer.yaml。#2439)
+ registerOperation(app, "listRepositoryApprovalRequests", {
+ run: async (e, ctx) => {
+ const repositoryId = positiveInt(e.repository_id);
+ return { repository_id: repositoryId, ...(await deps().organizer.list(ctx!, repositoryId)) };
+ },
});
- app.get("/accounts/:accountId/repositories/:repositoryId/releases", async (c) => {
- const ctx = await ctxOf(c);
+ registerList(app, "listRepositoryReleases", async (c, ctx) => {
const raw = c.req.query("limit");
const limit = raw === undefined ? 20 : Number(raw);
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new InvalidRequestError("limit は 1〜100 の整数です");
- return c.json(await deps().organizer.history(ctx, positiveInt(c.req.param("repositoryId")), limit));
+ return (await deps().organizer.history(ctx!, positiveInt(c.req.query("repository_id")), limit)).items;
});
- // 止まった配布を解く。画面の経路は Cognito の利用者の token だけを受ける(承認ゲートではなく失敗の扱い。決定 11)
- app.post("/accounts/:accountId/repositories/:repositoryId/releases/:seq/resolve", async (c) => {
- const ctx = await ctxOf(c);
- const seq = Number(c.req.param("seq"));
- if (!Number.isInteger(seq) || seq < 1) throw new InvalidRequestError("まとめた配布の連番の形ではありません");
- return c.json(await deps().organizer.resolve(ctx, positiveInt(c.req.param("repositoryId")), seq, await readJson(c)), 202);
+ // 止まった配布を解く。人だけが確定できる(扱い human。オーガナイザーの I66)
+ registerOperation(app, "resolveStalledRelease", {
+ run: (e, ctx) =>
+ deps().organizer.resolve(ctx!, positiveInt(e.repository_id), positiveInt(e.seq, "まとめた配布の連番の形ではありません"), { choice: e.choice }),
});
}
diff --git a/factory/api/src/routes/me.ts b/factory/api/src/routes/me.ts
index 4cd5a0a48..5732e8ee3 100644
--- a/factory/api/src/routes/me.ts
+++ b/factory/api/src/routes/me.ts
@@ -1,16 +1,19 @@
import type { Hono } from "hono";
import type { AuthEnv } from "../auth/resolve.ts";
+import { registerList } from "./operation.ts";
import { accountSummary } from "./views.ts";
export function registerMe(app: Hono): void {
- // 初回は個人のアカウントを作る(E2)
- app.get("/me", async (c) => {
+ // 初回は個人のアカウントを作る(E2)。要素 1 個の配列で返す
+ registerList(app, "getMe", async (c) => {
const { user, contexts } = await c.get("auth").accounts.listAccounts(c.get("caller"));
- return c.json({
- user_id: user.userId,
- idp: user.idp,
- personal_account_id: user.personalAccountId,
- accounts: contexts.map((ctx) => accountSummary(ctx, ctx.name)),
- });
+ return [
+ {
+ user_id: user.userId,
+ idp: user.idp,
+ personal_account_id: user.personalAccountId,
+ accounts: contexts.map((ctx) => accountSummary(ctx, ctx.name)),
+ },
+ ];
});
}
diff --git a/factory/api/src/routes/operation.ts b/factory/api/src/routes/operation.ts
new file mode 100644
index 000000000..bad0a7713
--- /dev/null
+++ b/factory/api/src/routes/operation.ts
@@ -0,0 +1,126 @@
+// 操作の登録の層(#2384 の決定 5・不変条件 I10・I12・I13)。経路を操作の表から登録し、要素の数と重複の検査・
+// 経路のアカウントの解決・要素ごとの実行・操作の記録へ渡す値を、すべての利用者向けの操作に同じく掛ける
+import type { Context, Hono } from "hono";
+import { MAX_ITEMS, operation, type Operation, type OperationId } from "@ndf-factory/contracts/operations";
+import type { AccountContext } from "../accounts/model.ts";
+import { resolveFromRequest, type AuthEnv, type OperationTrace } from "../auth/resolve.ts";
+import { InvalidRequestError } from "../errors.ts";
+import { runElements, type ElementResult } from "./elements.ts";
+
+export type Element = Record;
+
+export interface ElementHandler {
+ // 要求全体の前に 1 度だけ行う(条件の照合など)。投げると要求全体を断る
+ prepare?: (c: Context, ctx: AccountContext | null) => Promise;
+ // 要素 1 つの値を返す。ApiError を投げるとその要素だけが失敗の結果になる
+ run: (element: Element, ctx: AccountContext | null, shared: S, c: Context) => Promise;
+}
+
+const honoPath = (op: Operation) => op.path.replace("{accountId}", ":accountId");
+const method = (op: Operation) => op.method.toLowerCase() as "get" | "post" | "put" | "patch" | "delete";
+
+// 経路にアカウントがあれば今と同じく解決し、権限が無ければ要求全体を 403 にする(決定 2)
+async function contextOf(c: Context, op: Operation): Promise {
+ if (!op.path.includes("{accountId}")) return null;
+ return resolveFromRequest(c, c.req.param("accountId"));
+}
+
+function trace(c: Context, op: Operation): OperationTrace {
+ const t: OperationTrace = { operationId: op.operationId, accountId: op.path.includes("{accountId}") ? (c.req.param("accountId") ?? null) : null, targetIds: [], reasons: [] };
+ c.set("operation", t);
+ return t;
+}
+
+function queryValue(op: Operation, key: string, raw: string): unknown {
+ const schema = op.parameters.find((p) => p.name === key)?.schema as { items?: { type?: string } } | undefined;
+ return schema?.items?.type === "integer" && /^\d+$/.test(raw) ? Number(raw) : raw;
+}
+
+// 要素を取り出す。鍵の query の繰り返しか、本文の配列。鍵を持たない操作の取得と削除は要素が必ず 1 個
+async function elementsOf(c: Context, op: Operation): Promise {
+ if (op.kind === "select" || op.kind === "delete") {
+ if (op.keys.length === 0) return [{}];
+ const key = op.keys[0]!;
+ return (c.req.queries(key) ?? []).map((raw) => ({ [key]: queryValue(op, key, raw) }));
+ }
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ throw new InvalidRequestError("本文は要素の配列にしてください");
+ }
+ if (!Array.isArray(body)) throw new InvalidRequestError("本文は要素の配列にしてください");
+ return body;
+}
+
+const isElement = (v: unknown): v is Element => typeof v === "object" && v !== null && !Array.isArray(v);
+
+// 要素の数と重複の検査(I12)。どれかに当たれば、どの要素も処理しない
+function checkElements(op: Operation, elements: unknown[]): void {
+ if (elements.length === 0) throw new InvalidRequestError("要素を 1 個以上送ってください", "empty_items");
+ if (elements.length > MAX_ITEMS) {
+ throw new InvalidRequestError(`1 回に送れる要素は ${MAX_ITEMS} 個までです`, "too_many_items", { limit: MAX_ITEMS });
+ }
+ const seen = new Set();
+ for (const e of elements) {
+ const id = op.keys.length === 0 ? "" : JSON.stringify(op.keys.map((k) => (isElement(e) ? (e[k] ?? null) : null)));
+ if (seen.has(id)) {
+ throw new InvalidRequestError("同じ鍵の要素が 2 つあります", "duplicate_key", { key: op.keys.length === 0 ? null : op.keys.join("+") });
+ }
+ seen.add(id);
+ }
+}
+
+function pick(value: unknown, path: string): unknown {
+ return path.split(".").reduce((v, k) => (isElement(v) ? v[k] : undefined), value);
+}
+
+// 操作の記録へ渡す対象の ID(要素の鍵と作った識別子)と、失敗した要素の理由
+function record(t: OperationTrace, op: Operation, elements: unknown[], results: ElementResult[]): void {
+ elements.forEach((e, i) => {
+ for (const k of op.keys) {
+ const v = isElement(e) ? e[k] : undefined;
+ if (typeof v === "string" || typeof v === "number") t.targetIds.push(`${k}=${v}`);
+ }
+ const r = results[i];
+ if (op.idField && r?.ok) {
+ const v = pick(r.value, op.idField);
+ if (typeof v === "string" || typeof v === "number") t.targetIds.push(`${op.idField.split(".").at(-1)}=${v}`);
+ }
+ if (r && !r.ok && !t.reasons.includes(r.error.code)) t.reasons.push(r.error.code);
+ });
+}
+
+// 一覧(条件で集めた値の配列。要素の結果で包まない)
+export function registerList(
+ app: Hono,
+ operationId: OperationId,
+ list: (c: Context, ctx: AccountContext | null) => Promise,
+): void {
+ const op = operation(operationId);
+ if (op.kind !== "list") throw new Error(`${operationId} は一覧ではありません`);
+ app[method(op)](honoPath(op), async (c) => {
+ trace(c, op);
+ const ctx = await contextOf(c, op);
+ return c.json(await list(c, ctx));
+ });
+}
+
+// 一覧を除く操作(指定の取得・作成・更新・削除・操作)
+export function registerOperation(app: Hono, operationId: OperationId, handler: ElementHandler): void {
+ const op = operation(operationId);
+ if (op.kind === "list") throw new Error(`${operationId} は一覧です`);
+ app[method(op)](honoPath(op), async (c) => {
+ const t = trace(c, op);
+ const elements = await elementsOf(c, op);
+ checkElements(op, elements);
+ const ctx = await contextOf(c, op);
+ const shared = (handler.prepare ? await handler.prepare(c, ctx) : undefined) as S;
+ const results = await runElements(elements, (e) => {
+ if (!isElement(e)) throw new InvalidRequestError("要素はオブジェクトにしてください");
+ return handler.run(e, ctx, shared, c);
+ });
+ record(t, op, elements, results);
+ return c.json(results);
+ });
+}
diff --git a/factory/api/src/routes/sprint-views.test.ts b/factory/api/src/routes/sprint-views.test.ts
index 241ae60a9..c463e8cbb 100644
--- a/factory/api/src/routes/sprint-views.test.ts
+++ b/factory/api/src/routes/sprint-views.test.ts
@@ -12,7 +12,7 @@ import { LoopControl } from "../runs/loops.ts";
import type { SprintRun } from "../runs/model.ts";
import { createRunsHarness, type RunsHarness } from "../testing/runs.ts";
import { createTokenKit, type TokenKit } from "../testing/tokens.ts";
-import type { TestResponse } from "../testing/app.ts";
+import { firstResult, type TestResponse } from "../testing/app.ts";
import type { FactoryDeps } from "./factory.ts";
const noSecrets: SecretsPort = { create: async () => "arn", put: async () => undefined, delete: async () => undefined };
@@ -27,8 +27,14 @@ async function call(path: string, user = "u-1"): Promise {
return (await app.request(path, { headers: { Authorization: await tokens.bearer(user) } })) as TestResponse;
}
+// 要素 1 個の結果を状態と本文で読む(#2384)
+const read = async (path: string, user = "u-1") => firstResult(await call(path, user));
+const detailPath = (acc: string, id: string) => `/accounts/${acc}/sprint-runs/detail?sprint_run_id=${encodeURIComponent(id)}`;
+const stepPath = (acc: string, id: string, key: string) => `/accounts/${acc}/sprint-runs/steps?sprint_run_id=${encodeURIComponent(id)}&step_key=${encodeURIComponent(key)}`;
+const backlogPath = (acc: string, repositoryId: number) => `/accounts/${acc}/backlogs?repository_id=${repositoryId}`;
+
async function personal(user: string): Promise {
- return ((await (await call("/me", user)).json()) as { personal_account_id: string }).personal_account_id;
+ return ((await (await call("/me", user)).json())[0] as { personal_account_id: string }).personal_account_id;
}
function runOf(accountId: string, patch: Partial = {}): SprintRun {
@@ -128,7 +134,7 @@ describe("一覧の経路(受け入れ条件 1〜3・18)", () => {
const { acc } = await seed();
const res = await call(`/accounts/${acc}/sprint-runs`);
expect(res.status).toBe(200);
- const { sprint_runs } = await res.json();
+ const sprint_runs = await res.json();
expect(sprint_runs.map((r: { sprint_run_id: string }) => r.sprint_run_id)).toEqual(["sr-2", "sr-1"]);
const row = sprint_runs[1];
expect(row).toEqual({
@@ -150,15 +156,15 @@ describe("一覧の経路(受け入れ条件 1〜3・18)", () => {
describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)", () => {
it("詳細は本文を返さず、工程の中身は 1 件ずつ本文を返す。どちらも GitHub の口を呼ばない", async () => {
const { acc, step } = await seed();
- const detail = await call(`/accounts/${acc}/sprint-runs/sr-1`);
+ const detail = await read(detailPath(acc, "sr-1"));
expect(detail.status).toBe(200);
- const body = await detail.json();
+ const body = detail.body;
expect(body).toMatchObject({ sprint_run_id: "sr-1", repo: "org-a/repo-a", current_phase: "実装", ndf_version: "10.27.15", job_count: 1, omitted_steps: 0 });
expect(body.steps[0]).toMatchObject({ key: step, phase: "実装", issues: [11], has_details: true });
expect(JSON.stringify(body)).not.toContain("## 作業の報告");
- const one = await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`);
+ const one = await read(stepPath(acc, "sr-1", step));
expect(one.status).toBe(200);
- expect((await one.json()).output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false });
+ expect(one.body.output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false });
await call(`/accounts/${acc}/sprint-runs`);
expect(githubCalls).toEqual([]);
});
@@ -166,9 +172,9 @@ describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)"
it("足す前の形の step.recorded(schema_version 1 のまま)も詳細に出て、本文の欄は null", async () => {
const { acc } = await seed();
const key = await emit("sr-1", "step.recorded", { step: "pr", exit_code: 0, seconds: 1, summary: "作った" });
- const body = await (await call(`/accounts/${acc}/sprint-runs/sr-1`)).json();
+ const body = (await read(detailPath(acc, "sr-1"))).body;
expect(body.steps.map((s: { key: string }) => s.key)).toContain(key);
- const one = await (await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(key)}`)).json();
+ const one = (await read(stepPath(acc, "sr-1", key))).body;
expect([one.files, one.decisions, one.output]).toEqual([null, null, null]);
});
@@ -176,9 +182,9 @@ describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)"
const { acc, step } = await seed();
const before = JSON.stringify([h.progress.events, [...h.table.runs.values()]]);
await call(`/accounts/${acc}/sprint-runs`);
- await call(`/accounts/${acc}/sprint-runs/sr-1`);
- await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`);
- await call(`/accounts/${acc}/repositories/101/backlog`);
+ await call(detailPath(acc, "sr-1"));
+ await call(stepPath(acc, "sr-1", step));
+ await call(backlogPath(acc, 101));
expect(JSON.stringify([h.progress.events, [...h.table.runs.values()]])).toBe(before);
});
});
@@ -188,28 +194,24 @@ describe("認可(受け入れ条件 11・I12)", () => {
const { acc, step } = await seed();
const other = await personal("u-2");
h.table.runs.set("sr-other", runOf(other, { sprintRunId: "sr-other" }));
- const paths = [
- `/accounts/${acc}/sprint-runs`,
- `/accounts/${acc}/sprint-runs/sr-1`,
- `/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`,
- `/accounts/${acc}/repositories/101/backlog`,
- ];
+ const paths = [`/accounts/${acc}/sprint-runs`, detailPath(acc, "sr-1"), stepPath(acc, "sr-1", step), backlogPath(acc, 101)];
const missing = "00000000-0000-4000-8000-0000000000ff";
const cases = [
...paths.map((p) => [p, "u-2"] as const),
...paths.map((p) => [p.replace(acc, missing), "u-1"] as const),
- [`/accounts/${acc}/sprint-runs/sr-other`, "u-1"],
- [`/accounts/${acc}/sprint-runs/sr-none`, "u-1"],
- [`/accounts/${acc}/sprint-runs/sr-other/steps/${encodeURIComponent(step)}`, "u-1"],
- [`/accounts/${acc}/sprint-runs/sr-1/steps/job-1%3A999`, "u-1"],
- [`/accounts/${acc}/sprint-runs/sr-2/steps/${encodeURIComponent(step)}`, "u-1"],
- [`/accounts/${acc}/repositories/999/backlog`, "u-1"],
+ [detailPath(acc, "sr-other"), "u-1"],
+ [detailPath(acc, "sr-none"), "u-1"],
+ [stepPath(acc, "sr-other", step), "u-1"],
+ [stepPath(acc, "sr-1", "job-1:999"), "u-1"],
+ [stepPath(acc, "sr-2", step), "u-1"],
+ [backlogPath(acc, 999), "u-1"],
] as const;
+ // 要求全体の 403 と要素の forbidden は、同じ本文を持つ
const bodies = new Set();
for (const [path, user] of cases) {
- const res = await call(path, user);
+ const res = await read(path, user);
expect(res.status, path).toBe(403);
- bodies.add(await res.text());
+ bodies.add(JSON.stringify(res.body));
}
expect([...bodies]).toEqual([JSON.stringify({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } })]);
});
@@ -221,9 +223,9 @@ describe("バックログの経路(受け入れ条件 15・16・I13)", () =>
h.github.rankings.set("org-a/repo-a", { milestone: "25 見本", rows: [{ number: 5, title: "先に" }, { number: 3, title: "後に" }] });
await emit("sr-1", "step.recorded", { step: "refine", exit_code: 0, seconds: 3, summary: "古い棚卸し" }, "2026-11-02T00:00:00.000Z");
await emit("sr-2", "step.recorded", { step: "refine", exit_code: 1, seconds: 3, summary: "新しい棚卸し" }, "2026-11-03T00:00:00.000Z");
- const res = await call(`/accounts/${acc}/repositories/101/backlog`);
+ const res = await read(backlogPath(acc, 101));
expect(res.status).toBe(200);
- const body = await res.json();
+ const body = res.body;
expect(body.repo).toBe("org-a/repo-a");
expect(body.ranking).toEqual({
milestone: "25 見本",
@@ -238,12 +240,54 @@ describe("バックログの経路(受け入れ条件 15・16・I13)", () =>
it("順位の表が無ければ ranking は null、GitHub の口が失敗すれば 503", async () => {
const { acc } = await seed();
- const none = await (await call(`/accounts/${acc}/repositories/101/backlog`)).json();
+ const none = (await read(backlogPath(acc, 101))).body;
expect(none.ranking).toBeNull();
expect(none.refinements).toEqual([]);
h.github.findRankingTable = async () => {
throw new Error("GitHub 502");
};
- expect((await call(`/accounts/${acc}/repositories/101/backlog`)).status).toBe(503);
+ expect((await read(backlogPath(acc, 101))).status).toBe(503);
+ });
+});
+
+describe("配列の受け渡し(#2384 の受け入れ条件 6〜8・15、I12・I13)", () => {
+ it("3 個の要素のうち他人の 1 個だけが forbidden で、順と数は入力と同じ。他人のデータは応答に無い", async () => {
+ const { acc } = await seed();
+ const other = await personal("u-2");
+ h.table.runs.set("sr-other", runOf(other, { sprintRunId: "sr-other", repo: "org-x/secret-repo" }));
+ const res = await call(`/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-1&sprint_run_id=sr-other&sprint_run_id=sr-2`);
+ expect(res.status).toBe(200);
+ const results = await res.json();
+ expect(results.map((r: { ok: boolean; value?: { sprint_run_id: string }; error?: { code: string } }) => (r.ok ? r.value!.sprint_run_id : r.error!.code))).toEqual([
+ "sr-1",
+ "forbidden",
+ "sr-2",
+ ]);
+ expect(JSON.stringify(results)).not.toContain("secret-repo");
+ });
+
+ it("101 個の要素は、どれも処理せずに 400 と上限 100、0 個は 400", async () => {
+ const { acc } = await seed();
+ const reads: string[] = [];
+ const getRun = h.table.getRun.bind(h.table);
+ h.table.getRun = async (id: string) => {
+ reads.push(id);
+ return getRun(id);
+ };
+ const many = Array.from({ length: 101 }, (_, i) => `sprint_run_id=sr-${i}`).join("&");
+ const res = await call(`/accounts/${acc}/sprint-runs/detail?${many}`);
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toMatchObject({ code: "too_many_items", details: { limit: 100 } });
+ const empty = await call(`/accounts/${acc}/sprint-runs/detail`);
+ expect(empty.status).toBe(400);
+ expect((await empty.json()).error.code).toBe("empty_items");
+ expect(reads).toEqual([]);
+ });
+
+ it("同じ鍵の要素が 2 つあれば 400 duplicate_key", async () => {
+ const { acc } = await seed();
+ const res = await call(`/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-1&sprint_run_id=sr-1`);
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toMatchObject({ code: "duplicate_key", details: { key: "sprint_run_id" } });
});
});
diff --git a/factory/api/src/routes/sprint-views.ts b/factory/api/src/routes/sprint-views.ts
index 95407222d..ae8559ea5 100644
--- a/factory/api/src/routes/sprint-views.ts
+++ b/factory/api/src/routes/sprint-views.ts
@@ -1,39 +1,22 @@
// スプリントの一覧・詳細・工程の中身・バックログの読み出しの経路(#2172)。読むだけで、事象の表・実行の表・GitHub へ書かない(I14)。
-// アカウントは resolveFromRequest で解決し、他人のアカウント・属さない実行・無い実行・無い工程・登録の無いリポジトリは
-// どれも同じ本文の 403(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13)
-import type { Context, Hono } from "hono";
+// アカウントは操作の登録の層が解決する。属さない実行・無い実行・無い工程・登録の無いリポジトリの要素は、
+// どれも他人のアカウントと同じ本文の forbidden の結果(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13)
+import type { Hono } from "hono";
import type { AccountContext } from "../accounts/model.ts";
-import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts";
+import type { AuthEnv } from "../auth/resolve.ts";
import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.ts";
import type { RankingTable } from "../github/port.ts";
import type { SprintRun } from "../runs/model.ts";
import { DETAIL_STEP_LIMIT, deriveDetail, deriveRefinements, deriveStepDetail, listRowExtras, stepRows } from "../views/derive.ts";
-import type { FactoryDeps } from "./factory.ts";
+import { mapLimit } from "./elements.ts";
+import { positiveInt, type FactoryDeps } from "./factory.ts";
+import { registerOperation } from "./operation.ts";
// 一覧の行ごとの読み出しを並べる数の上限
const LIST_CONCURRENCY = 10;
// バックログで棚卸しの記録を読む実行の数(新しい順)
const BACKLOG_RUNS = 50;
-export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise {
- const out = new Array(items.length);
- let next = 0;
- const worker = async () => {
- while (next < items.length) {
- const i = next++;
- out[i] = await fn(items[i]!);
- }
- };
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
- return out;
-}
-
-function positiveInt(raw: string | undefined): number {
- const n = Number(raw);
- if (!Number.isInteger(n) || n < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません");
- return n;
-}
-
// 保存の失敗は 503(応答に中身を出さない)
async function readOrUnavailable(read: () => Promise): Promise {
try {
@@ -69,49 +52,57 @@ export async function sprintRunList(deps: FactoryDeps, ctx: AccountContext, repo
}
export function registerSprintViews(app: Hono, deps: () => FactoryDeps): void {
- const ctxOf = (c: Context) => resolveFromRequest(c, c.req.param("accountId"));
-
- app.get("/accounts/:accountId/sprint-runs/:sprintRunId", async (c) => {
- const ctx = await ctxOf(c);
- const d = deps();
- const run = await ownRun(d, ctx, c.req.param("sprintRunId"));
- const { events, omittedSteps } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT));
- return c.json(deriveDetail(run, events, omittedSteps));
+ registerOperation(app, "getSprintRunDetail", {
+ run: async (e, ctx) => {
+ const d = deps();
+ const run = await ownRun(d, ctx!, typeof e.sprint_run_id === "string" ? e.sprint_run_id : "");
+ const { events, omittedSteps } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT));
+ return deriveDetail(run, events, omittedSteps);
+ },
});
- app.get("/accounts/:accountId/sprint-runs/:sprintRunId/steps/:stepKey", async (c) => {
- const ctx = await ctxOf(c);
- const d = deps();
- const run = await ownRun(d, ctx, c.req.param("sprintRunId"));
- const key = c.req.param("stepKey");
- const [record, { events }] = await readOrUnavailable(() => Promise.all([d.reader.getStep(run.sprintRunId, key), d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)]));
- if (!record) throw new ForbiddenError();
- // 上限の外の古い工程は、その工程だけの列から行を作る(フェーズは payload.phase だけ・費用は結べない)
- const row = stepRows(events).find((r) => r.key === key) ?? stepRows([record])[0]!;
- return c.json(deriveStepDetail(run, row, record));
+ // 1 つの実行の中の工程の鍵を要素にする。実行は条件で、他人の実行なら要求全体が 403
+ registerOperation(app, "getStepDetail", {
+ prepare: async (c, ctx) => {
+ const ids = c.req.queries("sprint_run_id") ?? [];
+ if (ids.length !== 1) throw new InvalidRequestError("sprint_run_id を 1 つ指してください");
+ const d = deps();
+ const run = await ownRun(d, ctx!, ids[0]!);
+ const { events } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT));
+ return { run, rows: stepRows(events) };
+ },
+ run: async (e, _ctx, { run, rows }) => {
+ const key = typeof e.step_key === "string" ? e.step_key : "";
+ const record = await readOrUnavailable(() => deps().reader.getStep(run.sprintRunId, key));
+ if (!record) throw new ForbiddenError();
+ // 上限の外の古い工程は、その工程だけの列から行を作る(フェーズは payload.phase だけ・費用は結べない)
+ const row = rows.find((r) => r.key === key) ?? stepRows([record])[0]!;
+ return deriveStepDetail(run, row, record);
+ },
});
- app.get("/accounts/:accountId/repositories/:repositoryId/backlog", async (c) => {
- const ctx = await ctxOf(c);
- const d = deps();
- const repositoryId = positiveInt(c.req.param("repositoryId"));
- const registration = await d.table.getRegistration(ctx.accountId, repositoryId);
- if (!registration) throw new ForbiddenError();
- const runs = (await d.table.listRuns(ctx.accountId, 1000)).filter((r) => r.repositoryId === repositoryId).slice(0, BACKLOG_RUNS);
- const [ranking, perRun] = await Promise.all([
- d.github.findRankingTable(registration.installationId, repositoryId, registration.fullName).catch((): never => {
- throw new ServiceUnavailableError("github_unavailable");
- }),
- readOrUnavailable(() =>
- mapLimit(runs, LIST_CONCURRENCY, async (r) => ({ sprintRunId: r.sprintRunId, steps: await d.reader.listStepsNamed(r.sprintRunId, "refine") })),
- ),
- ]);
- return c.json({
- repository_id: repositoryId,
- repo: registration.fullName,
- ranking: rankingOf(ranking),
- refinements: deriveRefinements(perRun),
- });
+ registerOperation(app, "getBacklog", {
+ run: async (e, ctx) => {
+ const d = deps();
+ const repositoryId = positiveInt(e.repository_id);
+ const registration = await d.table.getRegistration(ctx!.accountId, repositoryId);
+ if (!registration) throw new ForbiddenError();
+ const runs = (await d.table.listRuns(ctx!.accountId, 1000)).filter((r) => r.repositoryId === repositoryId).slice(0, BACKLOG_RUNS);
+ const [ranking, perRun] = await Promise.all([
+ d.github.findRankingTable(registration.installationId, repositoryId, registration.fullName).catch((): never => {
+ throw new ServiceUnavailableError("github_unavailable");
+ }),
+ readOrUnavailable(() =>
+ mapLimit(runs, LIST_CONCURRENCY, async (r) => ({ sprintRunId: r.sprintRunId, steps: await d.reader.listStepsNamed(r.sprintRunId, "refine") })),
+ ),
+ ]);
+ return {
+ repository_id: repositoryId,
+ repo: registration.fullName,
+ ranking: rankingOf(ranking),
+ refinements: deriveRefinements(perRun),
+ };
+ },
});
}
diff --git a/factory/api/src/routes/teams.ts b/factory/api/src/routes/teams.ts
index 49910e565..77c0d275c 100644
--- a/factory/api/src/routes/teams.ts
+++ b/factory/api/src/routes/teams.ts
@@ -3,17 +3,22 @@ import { InvalidRequestError } from "../errors.ts";
import { isSeatType } from "../accounts/model.ts";
import { validateTeamName } from "../accounts/service.ts";
import type { AuthEnv } from "../auth/resolve.ts";
-import { readJson } from "./body.ts";
+import { parseIdempotencyKey } from "../idempotency.ts";
+import { registerOperation } from "./operation.ts";
import { accountDetail } from "./views.ts";
export function registerTeams(app: Hono): void {
- app.post("/teams", async (c) => {
- const body = await readJson(c);
- const name = validateTeamName(body.name);
- if (!isSeatType(body.seat_type)) throw new InvalidRequestError("席の種類は standard か premium です");
- const accounts = c.get("auth").accounts;
- const ctx = await accounts.createTeam(c.get("caller"), name, body.seat_type);
- const seats = await accounts.listSeats(ctx);
- return c.json(accountDetail(ctx, { name }, seats), 201);
+ // 同じ冪等の鍵の 2 回目は 1 回目のチームを返す(#2384 の I14)
+ registerOperation(app, "createTeam", {
+ run: async (e, _ctx, _s, c) => {
+ const name = validateTeamName(e.name);
+ if (!isSeatType(e.seat_type)) throw new InvalidRequestError("席の種類は standard か premium です");
+ const key = parseIdempotencyKey(e.idempotency_key);
+ const accounts = c.get("auth").accounts;
+ const ctx = await accounts.createTeam(c.get("caller"), name, e.seat_type, key);
+ const account = await accounts.getAccount(ctx.accountId);
+ const seats = await accounts.listSeats(ctx);
+ return accountDetail(ctx, { name: account?.name ?? name }, seats);
+ },
});
}
diff --git a/factory/api/src/runs/control.ts b/factory/api/src/runs/control.ts
index edef472e5..c380befe6 100644
--- a/factory/api/src/runs/control.ts
+++ b/factory/api/src/runs/control.ts
@@ -31,6 +31,7 @@ import {
import type { RunnerPlatform } from "./platform.ts";
import type { RunsTable } from "./table.ts";
import { isLegacyVersion } from "./version.ts";
+import { claimOf, type IdempotencyRef } from "../idempotency.ts";
export const DEFAULT_TIMEOUT_SECONDS = 28_800;
export const DEFAULT_TOKEN_TTL_SECONDS = 32_400;
@@ -124,6 +125,8 @@ export class ResumeRejectedError extends Error {
export interface StartInput {
repositoryId: unknown;
issues: unknown;
+ // 作成の冪等の鍵(経路が形を確かめて渡す。#2384 の I14)。無ければ鍵の行を書かない
+ idempotencyKey?: string;
}
// 手元のマシンの申告(#2189 の登録と心拍。契約の local-runner-declaration)
@@ -194,6 +197,11 @@ export class RunControl {
) {
throw new InvalidRequestError(`課題は 1〜${MAX_ISSUES} 件の番号で選んでください`, "invalid_issues", { numbers: [] });
}
+ const idempotency: IdempotencyRef | undefined =
+ input.idempotencyKey === undefined ? undefined : { userId: ctx.userId, operationId: "startSprintRun", key: input.idempotencyKey };
+ // 同じ鍵の 2 回目は 1 回目の結果を返し、何も確かめず何も書かない
+ const replayed = idempotency ? await table.getIdempotent(idempotency, this.iso()) : null;
+ if (replayed) return { sprint_run_id: replayed, state: "starting" };
const registration = await table.getRegistration(ctx.accountId, repositoryId as number);
if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません");
const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues as number[]);
@@ -207,7 +215,14 @@ export class RunControl {
milestoneTitles: titles,
milestonesOf: check.milestones ?? {},
start: "new",
+ ...(idempotency ? { idempotency } : {}),
});
+ if (created === "duplicate") {
+ // 同じ鍵の作成が同時に走り、先に書いた方が勝った
+ const won = await table.getIdempotent(idempotency!, this.iso());
+ if (!won) throw new ServiceUnavailableError("sprint_run_unavailable");
+ return { sprint_run_id: won, state: "starting" };
+ }
if (created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable");
return { sprint_run_id: created.sprintRunId, state: "starting" };
}
@@ -246,8 +261,9 @@ export class RunControl {
milestonesOf: Record;
start: "new" | "loop";
loop?: { loop: DevLoop; write: (run: SprintRun) => LoopWrite };
+ idempotency?: IdempotencyRef;
},
- ): Promise {
+ ): Promise {
const { table, github } = this.deps;
let modelCredentialSecretArn: string | null = null;
if (ctx.executionMode === "byok") {
@@ -315,13 +331,14 @@ export class RunControl {
milestoneChecks: opts.milestoneTitles,
legacy,
...(opts.loop ? { loop: opts.loop.write(run) } : {}),
+ ...(opts.idempotency ? { idempotency: claimOf(opts.idempotency, run.sprintRunId, now) } : {}),
};
const result = await table.createRun(run, job, slots, guards);
if (result === "created") {
await this.launch({ ...run, legacy }, job);
return { ...run, legacy };
}
- if (result === "loop_changed") return "loop_changed";
+ if (result === "loop_changed" || result === "duplicate") return result;
// 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62)
if (result === "release_running") {
throw new ConflictError("parallel_unsupported_version", "このリポジトリでは、並行を許さない NDF の版の実行と並べて動かせません");
@@ -357,7 +374,9 @@ export class RunControl {
const registration = await this.deps.table.getRegistration(loop.accountId, loop.repositoryId);
if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません");
const ctx: AccountContext = { accountId: loop.accountId, ...loop.context };
- return this.admit(ctx, registration, { issues: [], milestoneTitles: [], milestonesOf: {}, start: "loop", loop: { loop, write } });
+ const created = await this.admit(ctx, registration, { issues: [], milestoneTitles: [], milestonesOf: {}, start: "loop", loop: { loop, write } });
+ // 周回の開始は冪等の鍵の行を書かないため duplicate にならない
+ return created === "duplicate" ? "loop_changed" : created;
}
// その UTC の暦月に、アカウントの managed の実行が使った token(形態 managed の上限の判定に使う)。
diff --git a/factory/api/src/runs/dynamo-table.ts b/factory/api/src/runs/dynamo-table.ts
index 8a96af074..3ecf26241 100644
--- a/factory/api/src/runs/dynamo-table.ts
+++ b/factory/api/src/runs/dynamo-table.ts
@@ -28,6 +28,7 @@ import type {
} from "./model.ts";
import { isTerminalJob, milestoneHash } from "./model.ts";
import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts";
+import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts";
export const GSI1 = "gsi1";
const ACTIVE_JOBS = "ACTIVE_JOBS";
@@ -256,6 +257,9 @@ export class DynamoRunsTable implements RunsTable {
items.push({ Put: { TableName: this.tableName, Item: { pk: accountPk(run.accountId), sk: loopRunSk(run.loopId, run.sprintRunId), sprintRunId: run.sprintRunId } } });
}
// 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62。オーガナイザーの根を読むだけ)
+ // 作成の冪等の鍵の行(#2384 の I14)。期限を過ぎた行は上書きする
+ const idempotencyAt = items.length;
+ if (guards?.idempotency) items.push(this.idempotencyPut(guards.idempotency));
const organizerAt = items.length;
if (guards?.legacy) {
items.push({
@@ -273,6 +277,7 @@ export class DynamoRunsTable implements RunsTable {
} catch (err) {
const failed = failedIndexes(err);
if (failed.length === 0 && !isConditionFailure(err)) throw err;
+ if (guards?.idempotency && failed.includes(idempotencyAt)) return "duplicate";
if (guards?.legacy && failed.includes(organizerAt)) return "release_running";
if (failed.some((i) => i >= loopStart)) return "loop_changed";
if (failed.some((i) => i >= milestoneStart)) return "issue_in_loop";
@@ -732,8 +737,24 @@ export class DynamoRunsTable implements RunsTable {
);
}
- async createLoop(loop: DevLoop): Promise<"created" | { conflict: string }> {
- const items = [
+ private idempotencyPut(claim: IdempotencyClaim): Record {
+ return {
+ Put: {
+ TableName: this.tableName,
+ Item: { pk: idempotencyPk(claim), sk: "IDEMP", createdId: claim.createdId, createdAt: claim.createdAt, ttl: claim.ttl },
+ ConditionExpression: "attribute_not_exists(pk) OR #ttl < :now",
+ ExpressionAttributeNames: { "#ttl": "ttl" },
+ ExpressionAttributeValues: { ":now": epochSeconds(claim.createdAt) },
+ },
+ };
+ }
+
+ async getIdempotent(ref: IdempotencyRef, now: string): Promise {
+ return liveCreatedId(await this.get<{ createdId: string; ttl: number }>(idempotencyPk(ref), "IDEMP"), now);
+ }
+
+ async createLoop(loop: DevLoop, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate" | { conflict: string }> {
+ const items: Record[] = [
...loop.milestones.map((title) => ({
Put: {
TableName: this.tableName,
@@ -754,11 +775,15 @@ export class DynamoRunsTable implements RunsTable {
},
},
];
+ const idempotencyAt = items.length;
+ if (idempotency) items.push(this.idempotencyPut(idempotency));
try {
await this.doc.send(new TransactWriteCommand({ TransactItems: items as never }));
return "created";
} catch (err) {
- const failed = failedIndexes(err).filter((i) => i < loop.milestones.length);
+ const all = failedIndexes(err);
+ if (idempotency && all.includes(idempotencyAt)) return "duplicate";
+ const failed = all.filter((i) => i < loop.milestones.length);
if (failed.length === 0 && !isConditionFailure(err)) throw err;
return { conflict: loop.milestones[failed[0] ?? 0]! };
}
diff --git a/factory/api/src/runs/loops.ts b/factory/api/src/runs/loops.ts
index 9c5711f5a..aa470b992 100644
--- a/factory/api/src/runs/loops.ts
+++ b/factory/api/src/runs/loops.ts
@@ -22,6 +22,7 @@ import {
type WaitReason,
} from "./model.ts";
import type { RunsTable } from "./table.ts";
+import { claimOf, parseIdempotencyKey, type IdempotencyRef } from "../idempotency.ts";
export const LOOP_NAME = /^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$/;
export const MAX_MILESTONES = 10;
@@ -196,6 +197,11 @@ export class LoopControl {
throw new InvalidRequestError(`マイルストーンは 1〜${MAX_MILESTONES} 件、各 ${MAX_MILESTONE_LENGTH} 字までで , を含めずに選んでください`);
}
const budget = parseBudget(body.budget);
+ // 作成の冪等の鍵(#2384 の I14)。経路は必ず渡す。同じ鍵の 2 回目は 1 回目のループを返す
+ const idempotency: IdempotencyRef | undefined =
+ body.idempotency_key === undefined ? undefined : { userId: ctx.userId, operationId: "startLoop", key: parseIdempotencyKey(body.idempotency_key) };
+ const replayed = idempotency ? await this.replayLoop(ctx, idempotency) : null;
+ if (replayed) return replayed;
const registration = await table.getRegistration(ctx.accountId, repositoryId);
if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません");
const same = (await table.listLoops(ctx.accountId)).find((l) => l.repositoryId === repositoryId && l.name === name && !isTerminalLoop(l.state));
@@ -226,7 +232,12 @@ export class LoopControl {
updatedAt: now,
lastRunAt: null,
};
- const created = await table.createLoop(loop);
+ const created = await table.createLoop(loop, idempotency && claimOf(idempotency, loop.loopId, now));
+ if (created === "duplicate") {
+ const won = await this.replayLoop(ctx, idempotency!);
+ if (!won) throw new ConflictError("loop_busy", "ループの状態が変わりました。やり直してください");
+ return won;
+ }
if (created !== "created") {
const share = await table.getMilestoneShare(ctx.accountId, repositoryId, created.conflict);
const other = share ? await table.getLoop(ctx.accountId, share.loopId) : null;
@@ -242,6 +253,14 @@ export class LoopControl {
return { loop_id: loop.loopId, state: after?.state ?? loop.state };
}
+ // 期限の中の同じ鍵が作ったループの今の状態
+ private async replayLoop(ctx: AccountContext, ref: IdempotencyRef): Promise<{ loop_id: string; state: DevLoop["state"] } | null> {
+ const loopId = await this.deps.table.getIdempotent(ref, this.iso());
+ if (!loopId) return null;
+ const loop = await this.deps.table.getLoop(ctx.accountId, loopId);
+ return { loop_id: loopId, state: loop?.state ?? "between_laps" };
+ }
+
// 周回の開始(決定 2、I44・I52)。between_laps で今の実行が無いループだけを起こす。条件で落ちれば読み直して 3 回まで
async startNextLap(accountId: string, loopId: string, byUser: boolean): Promise {
const { table, control } = this.deps;
diff --git a/factory/api/src/runs/memory-table.ts b/factory/api/src/runs/memory-table.ts
index f17bbcb23..32368112c 100644
--- a/factory/api/src/runs/memory-table.ts
+++ b/factory/api/src/runs/memory-table.ts
@@ -17,6 +17,7 @@ import {
type SprintRun,
} from "./model.ts";
import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts";
+import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts";
const regKey = (a: string, r: number) => `${a}#${r}`;
const shareKey = (a: string, r: number, k: string | number) => `${a}#${r}#${k}`;
@@ -38,6 +39,8 @@ export class MemoryRunsTable implements RunsTable {
readonly runnerSlots = new Map();
// # → 古い版の実行を数えに入れた回数(#2439 の I62)
readonly legacySeqs = new Map();
+ // 作成の冪等の鍵の行(IDEMP#<利用者>#<操作>#<鍵>)。TTL で消さない
+ readonly idempotency = new Map();
// Factory のオーガナイザー(#2439)。#(#)→ 行
readonly organizers = new Map();
readonly orgRequests = new Map();
@@ -155,6 +158,7 @@ export class MemoryRunsTable implements RunsTable {
if (guards.issues.some((n) => this.issueShares.has(shareKey(run.accountId, run.repositoryId, n)))) return "issue_in_use";
if (guards.milestoneChecks.some((t) => this.milestoneShares.has(shareKey(run.accountId, run.repositoryId, t)))) return "issue_in_loop";
if (guards.loop && !this.loopFits(guards.loop)) return "loop_changed";
+ if (guards.idempotency && !this.idempotencyFree(guards.idempotency)) return "duplicate";
}
this.accountSlots.set(run.accountId, expected.account + 1);
this.repositorySlots.set(repoKey, expected.repository + 1);
@@ -165,6 +169,7 @@ export class MemoryRunsTable implements RunsTable {
}
for (const n of guards.issues) this.issueShares.set(shareKey(run.accountId, run.repositoryId, n), run.sprintRunId);
if (guards.loop) this.applyLoop(guards.loop);
+ if (guards.idempotency) this.idempotency.set(idempotencyPk(guards.idempotency), { ...guards.idempotency });
}
this.runs.set(run.sprintRunId, structuredClone({ ...run, ...(guards ? { legacy: guards.legacy } : {}) }));
this.jobs.set(job.jobId, structuredClone(job));
@@ -312,11 +317,24 @@ export class MemoryRunsTable implements RunsTable {
return true;
}
- async createLoop(loop: DevLoop) {
+ // 期限の中の同じ鍵の行が無い(DynamoDB の attribute_not_exists(pk) OR ttl < :now と同じ)
+ private idempotencyFree(claim: IdempotencyClaim): boolean {
+ const row = this.idempotency.get(idempotencyPk(claim));
+ return !row || row.ttl < epochSeconds(claim.createdAt);
+ }
+
+ async getIdempotent(ref: IdempotencyRef, now: string) {
+ await this.tick();
+ return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now);
+ }
+
+ async createLoop(loop: DevLoop, idempotency?: IdempotencyClaim) {
await this.tick();
for (const title of loop.milestones) {
if (this.milestoneShares.has(shareKey(loop.accountId, loop.repositoryId, title))) return { conflict: title };
}
+ if (idempotency && !this.idempotencyFree(idempotency)) return "duplicate" as const;
+ if (idempotency) this.idempotency.set(idempotencyPk(idempotency), { ...idempotency });
for (const title of loop.milestones) this.milestoneShares.set(shareKey(loop.accountId, loop.repositoryId, title), { loopId: loop.loopId, title });
this.loops.set(loop.loopId, structuredClone(loop));
return "created" as const;
diff --git a/factory/api/src/runs/model.ts b/factory/api/src/runs/model.ts
index 33a79a0b8..551965454 100644
--- a/factory/api/src/runs/model.ts
+++ b/factory/api/src/runs/model.ts
@@ -1,6 +1,7 @@
// 集約「スプリントの実行」「実行の枠」「開発ループ」と、同じ表に置くリポジトリの登録・持ち込んだキーの行(#2186・#2347 の設計の「データ構造」)
import { createHash } from "node:crypto";
import type { AccountContext, ExecutionMode } from "../accounts/model.ts";
+import type { IdempotencyClaim } from "../idempotency.ts";
// awaiting_reset は local の利用上限のリセット時刻を待つ(終わりの状態でない。枠を数え、今のジョブは空。#2347 の決定 7)
export const RUN_STATES = ["starting", "running", "awaiting_approval", "awaiting_reset", "done", "stopped", "failed", "cancelled"] as const;
@@ -213,10 +214,12 @@ export interface CreateRunGuards {
legacy: boolean;
// ループの周回の開始。ループの行を条件つきで書く
loop?: LoopWrite;
+ // 作成の冪等の鍵の行(手動の開始。#2384 の I14)。期限の中の同じ鍵の行があれば duplicate
+ idempotency?: IdempotencyClaim;
}
// release_running は、古い版の実行の開始で、リポジトリのまとめた配布が流れていた(#2439 の I62)
-export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running";
+export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running" | "duplicate";
// マイルストーンの持ち分の行の鍵に使う、題の sha256 の先頭 32 字
export const milestoneHash = (title: string) => createHash("sha256").update(title, "utf8").digest("hex").slice(0, 32);
diff --git a/factory/api/src/runs/table.ts b/factory/api/src/runs/table.ts
index 231906837..bc84c3712 100644
--- a/factory/api/src/runs/table.ts
+++ b/factory/api/src/runs/table.ts
@@ -15,6 +15,7 @@ import type {
Slots,
SprintRun,
} from "./model.ts";
+import type { IdempotencyClaim, IdempotencyRef } from "../idempotency.ts";
// 終わりへの移り・承認待ち・リセット待ちに添える書き込み(決定 2。ループは実行と同じトランザクションで書く)
export interface RunTransitionExtras {
@@ -98,7 +99,10 @@ export interface RunsTable {
// ---- 開発ループ(#2347) ----
// ループの行とマイルストーンの持ち分を 1 回で書く。持ち分が既にあれば、重なった題を返して書かない(I45)
- createLoop(loop: DevLoop): Promise<"created" | { conflict: string }>;
+ // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書き、期限の中の同じ鍵があれば duplicate(#2384 の I14)
+ createLoop(loop: DevLoop, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate" | { conflict: string }>;
+ // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う
+ getIdempotent(ref: IdempotencyRef, now: string): Promise;
getLoop(accountId: string, loopId: string): Promise;
// アカウントのループの新しい順
listLoops(accountId: string): Promise;
diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts
index c49b7a038..28f2afeef 100644
--- a/factory/api/src/testing/app.ts
+++ b/factory/api/src/testing/app.ts
@@ -1,3 +1,4 @@
+import { operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations";
import { AccountService } from "../accounts/service.ts";
import { MemoryAccountStore } from "../accounts/memory-store.ts";
import { createApiApp, type LogEntry } from "../handlers/api.ts";
@@ -6,6 +7,30 @@ import { createTokenKit, type TokenKit } from "./tokens.ts";
// テストで本文を読みやすくするため、json() の型を any にした応答
export type TestResponse = Omit & { json(): Promise };
+// 操作の呼び出し(#2384)。経路と query は操作の表から組む。items は本文の要素の配列
+export interface OpOptions {
+ user?: string;
+ idp?: "Google" | "GitHub";
+ authorization?: string;
+ accountId?: string;
+ query?: Record;
+ items?: unknown;
+}
+
+export function opRequest(operationId: OperationId, opts: OpOptions): { method: string; path: string; body: unknown } {
+ const op = operation(operationId);
+ return { method: op.method, path: operationUrl(op, opts.accountId ?? null, opts.query), body: opts.items };
+}
+
+// 要素 1 個の要求の結果を、今までの 1 件の応答と同じ形(状態と本文)で読む。要求全体を断ったときはその状態と本文
+export async function firstResult(res: TestResponse): Promise<{ status: number; body: any }> {
+ const data = await res.json();
+ if (res.status !== 200 || !Array.isArray(data)) return { status: res.status, body: data };
+ const r = data[0];
+ if (r && "ok" in r) return r.ok ? { status: 200, body: r.value } : { status: r.status, body: { error: r.error } };
+ return { status: 200, body: r };
+}
+
export interface TestApi {
app: ReturnType;
store: MemoryAccountStore;
@@ -13,6 +38,7 @@ export interface TestApi {
tokens: TokenKit;
logs: LogEntry[];
call(method: string, path: string, opts?: { user?: string; idp?: "Google" | "GitHub"; body?: unknown; authorization?: string }): Promise;
+ op(operationId: OperationId, opts?: OpOptions): Promise;
}
export async function createTestApi(): Promise {
@@ -22,7 +48,7 @@ export async function createTestApi(): Promise {
const tokens = await createTokenKit();
const logs: LogEntry[] = [];
const app = createApiApp(() => ({ verifier: tokens.verifier, accounts: service }), (e) => logs.push(e));
- return {
+ const api: TestApi = {
app,
store,
service,
@@ -39,5 +65,10 @@ export async function createTestApi(): Promise {
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})) as TestResponse;
},
+ op(operationId, opts = {}) {
+ const { method, path, body } = opRequest(operationId, opts);
+ return api.call(method, path, { ...opts, body });
+ },
};
+ return api;
}
diff --git a/factory/web/scripts/check-source.ts b/factory/web/scripts/check-source.ts
index ce3c25f3d..2e1e2bd59 100644
--- a/factory/web/scripts/check-source.ts
+++ b/factory/web/scripts/check-source.ts
@@ -1,4 +1,5 @@
-// 画面・部品・文脈のコードに表示の文言を直に書いていないか、画面がダミーの実装を import していないかを見る
+// 画面・部品・文脈のコードに表示の文言を直に書いていないか、画面がダミーの実装を import していないか、
+// API への要求の経路を文字列で直に組んでいないか(要求は操作の表から組む。#2384 の受け入れ条件 1)を見る
// 対象は src/**/*.{ts,tsx}。*.test.*・src/dummy/・翻訳のファイル(JSON)は見ない。コメントは見ない
// 終了コード: 0 無し / 1 見つけた / 2 構文解析できない
import { readFileSync, readdirSync } from "node:fs";
@@ -10,13 +11,15 @@ export interface Finding {
file: string;
line: number;
column: number;
- rule: "jsx-text" | "jsx-attr" | "jsx-expr" | "non-ascii" | "dummy-import";
+ rule: "jsx-text" | "jsx-attr" | "jsx-expr" | "non-ascii" | "dummy-import" | "api-path";
text: string;
}
const LETTER = /\p{L}/u;
const NON_ASCII = /[^\x00-\x7f]/;
const TEXT_ATTRS = new Set(["aria-label", "title", "placeholder", "alt", "label"]);
+// 要求を送る関数の名前(fetch・doFetch・request など)
+const REQUEST_CALLEE = /fetch|request/i;
interface Node {
type: string;
@@ -101,6 +104,21 @@ export function checkSource(file: string, code: string): Finding[] {
}
break;
}
+ case "CallExpression": {
+ const callee = node.callee as Node;
+ const name = callee.type === "Identifier" ? (callee.name as string) : callee.type === "MemberExpression" ? ((callee.property as Node).name as string | undefined) : undefined;
+ if (name && REQUEST_CALLEE.test(name)) {
+ for (const arg of node.arguments as Node[]) {
+ // 先頭が / の文字列か、先頭の固定の部分が / のテンプレート
+ const head = arg.type === "Literal" ? stringOf(arg) : arg.type === "TemplateLiteral" ? ((arg.quasis as Array<{ value: { cooked: string | null } }>)[0]?.value.cooked ?? "") : null;
+ if (head !== null && head.startsWith("/")) {
+ report(arg, "api-path", stringOf(arg) ?? head);
+ reported.add(arg);
+ }
+ }
+ }
+ break;
+ }
case "ImportDeclaration":
case "ImportExpression":
case "ExportAllDeclaration":
diff --git a/factory/web/src/App.test.tsx b/factory/web/src/App.test.tsx
index e6cc432b4..21ae5f77d 100644
--- a/factory/web/src/App.test.tsx
+++ b/factory/web/src/App.test.tsx
@@ -70,7 +70,10 @@ function show(path: string, routes: Record Response> = {}, a: Auth
);
}
-const ok = (body: unknown) => () => Response.json(body);
+// 一覧は値の配列、ほかの操作は要素の結果の配列で返す(#2384)
+const ok = (body: unknown) => () => Response.json([body]);
+const done = (value: unknown) => () => Response.json([{ ok: true, value }]);
+const failed = (status: number, error: unknown) => () => Response.json([{ ok: false, status, error }]);
const where = () => screen.getByTestId("where").textContent;
describe("ログインの画面(受け入れ条件 1・2)", () => {
@@ -93,7 +96,7 @@ describe("ログインの画面(受け入れ条件 1・2)", () => {
it("戻りの後に自分のアカウントの画面へ移り、料金プランが出る", async () => {
const stub: Auth = { ...auth, completeLogin: async () => (sessionStorage.setItem("ndf-factory.access_token", "t"), { ok: true }) };
- show("/callback?code=c&state=s", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }, stub);
+ show("/callback?code=c&state=s", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) }, stub);
expect(await screen.findByText("料金プラン: Free")).toBeTruthy();
expect(where()).toBe("/account");
});
@@ -103,7 +106,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => {
beforeEach(() => sessionStorage.setItem("ndf-factory.access_token", "t"));
it("2 つの上限と実行の形態と、席を持つチームが出る", async () => {
- show("/account", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) });
+ show("/account", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) });
expect(await screen.findByText("同時に動かせるスプリントの実行")).toBeTruthy();
expect(screen.getByText("1")).toBeTruthy();
expect(screen.getByText("掛けない")).toBeTruthy();
@@ -113,7 +116,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => {
});
it("ログアウトで token が消え、自分のアカウントを開くとログインの画面へ移る", async () => {
- show("/account", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) });
+ show("/account", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) });
await userEvent.click(await screen.findByRole("button", { name: "ログアウト" }));
expect(auth.getToken()).toBeNull();
expect(visited[0]).toContain("https://auth.example.test/logout");
@@ -133,7 +136,7 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () =
beforeEach(() => sessionStorage.setItem("ndf-factory.access_token", "t"));
it("席を持つ利用者には上限と席の一覧が出て、席の操作と形態の変更は出ない", async () => {
- show("/accounts/t-1", { "GET /accounts/t-1": ok(team) });
+ show("/accounts/t-1", { "GET /accounts?account_id=t-1": done(team) });
expect(await screen.findByText("125,000,000")).toBeTruthy();
expect(screen.getByText("10")).toBeTruthy();
expect(screen.getAllByRole("listitem")).toHaveLength(2);
@@ -144,9 +147,8 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () =
it("持ち主には操作が出て、API の 422 の文を操作の下に出す", async () => {
show("/accounts/t-1", {
- "GET /accounts/t-1": ok({ ...team, role: "owner" }),
- "POST /accounts/t-1/seats": () =>
- Response.json({ error: { code: "user_not_found", message: "その利用者はまだログインしていません" } }, { status: 422 }),
+ "GET /accounts?account_id=t-1": done({ ...team, role: "owner" }),
+ "POST /accounts/t-1/seats": failed(422, { code: "user_not_found", message: "その利用者はまだログインしていません" }),
});
await userEvent.type(await screen.findByLabelText("足す利用者の ID"), "22222222-2222-4222-8222-222222222222");
await userEvent.click(screen.getByRole("button", { name: "席を足す" }));
@@ -154,7 +156,7 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () =
});
it("利用者の ID の形でなければ送らない", async () => {
- show("/accounts/t-1", { "GET /accounts/t-1": ok({ ...team, role: "owner" }) });
+ show("/accounts/t-1", { "GET /accounts?account_id=t-1": done({ ...team, role: "owner" }) });
await userEvent.type(await screen.findByLabelText("足す利用者の ID"), "octo");
await userEvent.click(screen.getByRole("button", { name: "席を足す" }));
expect(await screen.findByText("利用者の ID の形ではありません")).toBeTruthy();
@@ -172,9 +174,9 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める
it("登録が 0 件なら導入の案内と「まだリポジトリがありません」を出す", async () => {
show("/accounts/p-1/repositories", {
- "GET /accounts/p-1": ok(owner),
+ "GET /accounts?account_id=p-1": done(owner),
"GET /accounts/p-1/github/install-url": ok({ url: "https://github.com/apps/x/installations/new" }),
- "GET /accounts/p-1/repositories": ok({ repositories: [] }),
+ "GET /accounts/p-1/repositories": () => Response.json([]),
});
expect(await screen.findByText("まだリポジトリがありません")).toBeTruthy();
expect(screen.getByRole("link", { name: "GitHub App をリポジトリへ入れる" }).getAttribute("href")).toContain("/installations/new");
@@ -183,10 +185,10 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める
it("持ち主でない席の人には登録と外すを出さない", async () => {
show("/accounts/t-1/repositories", {
- "GET /accounts/t-1": ok(team),
+ "GET /accounts?account_id=t-1": done(team),
"GET /accounts/t-1/github/install-url": ok({ url: "https://github.com/apps/x/installations/new" }),
- "GET /accounts/t-1/repositories": ok({ repositories: [{ repository_id: 1, full_name: "o/r", installation_id: 2, registered_at: "t", active_sprint_runs: 0 }] }),
- "GET /accounts/t-1/sprint-runs": ok({ sprint_runs: [{ sprint_run_id: "s", repository_id: 1, issues: [3], state: "failed", failure_reason: "timeout", created_at: "t" }] }),
+ "GET /accounts/t-1/repositories": ok({ repository_id: 1, full_name: "o/r", installation_id: 2, registered_at: "t", active_sprint_runs: 0 }),
+ "GET /accounts/t-1/sprint-runs": ok({ sprint_run_id: "s", repository_id: 1, issues: [3], state: "failed", failure_reason: "timeout", created_at: "t" }),
});
expect(await screen.findByText("o/r")).toBeTruthy();
expect(screen.getByText("#3: 失敗(timeout)")).toBeTruthy();
@@ -198,7 +200,7 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める
let saved: unknown = null;
show("/accounts/p-1/model-credential", {
"GET /accounts/p-1/model-credential": ok({ registered: false, kind: null, updated_at: null, external_id: "ndf-factory:p-1" }),
- "PUT /accounts/p-1/model-credential": () => ((saved = true), Response.json({ registered: true, kind: "anthropic_api_key", updated_at: "2026-10-31", external_id: "ndf-factory:p-1" })),
+ "PUT /accounts/p-1/model-credential": () => ((saved = true), done({ registered: true, kind: "anthropic_api_key", updated_at: "2026-10-31", external_id: "ndf-factory:p-1" })()),
});
await userEvent.click(await screen.findByRole("button", { name: "保存する" }));
expect(screen.getByRole("alert").textContent).toBe("API キーを入力してください");
@@ -211,7 +213,7 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める
it("AI の候補を理由つきで出し、上限の 409 を本数で知らせる", async () => {
show("/accounts/p-1/repositories/1/sprints/new", {
- "POST /accounts/p-1/repositories/1/issue-candidates": ok({
+ "POST /accounts/p-1/issue-candidates": done({
source: "ranking",
milestone: "03 x",
note: null,
@@ -220,11 +222,11 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める
{ number: 6, title: "六", selected: false, reason: "後でよい" },
],
}),
- "POST /accounts/p-1/sprint-runs": () =>
- Response.json(
- { error: { code: "limits_exceeded", message: "x", details: { reasons: [{ quantity: "concurrent_sprint_runs", limit: 1, current: 1 }] } } },
- { status: 409 },
- ),
+ "POST /accounts/p-1/sprint-runs": failed(409, {
+ code: "limits_exceeded",
+ message: "x",
+ details: { reasons: [{ quantity: "concurrent_sprint_runs", limit: 1, current: 1 }] },
+ }),
});
expect(await screen.findByText("順位が高い")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "開始する" }));
diff --git a/factory/web/src/api.test.ts b/factory/web/src/api.test.ts
new file mode 100644
index 000000000..5552287d9
--- /dev/null
+++ b/factory/web/src/api.test.ts
@@ -0,0 +1,108 @@
+// 画面の API クライアントと契約の対応(#2384 の受け入れ条件 1、性能の条件)
+import { describe, expect, it } from "vitest";
+import { MAX_ITEMS, OPERATIONS } from "@ndf-factory/contracts/operations";
+import { createApi, type Api } from "./api.ts";
+import type { Auth } from "./auth.ts";
+
+const API_URL = "https://api.example.test";
+const auth = { getToken: () => "t", clearToken: () => {} } as unknown as Auth;
+
+interface Sent {
+ method: string;
+ path: string;
+ query: URLSearchParams;
+ body: unknown;
+}
+
+// 送った要求を記録し、要素の数だけ成功の結果を返す偽の API
+function recorder() {
+ const sent: Sent[] = [];
+ const doFetch: typeof fetch = async (input, init) => {
+ const url = new URL(String(input));
+ const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
+ sent.push({ method: init?.method ?? "GET", path: url.pathname, query: url.searchParams, body });
+ const op = OPERATIONS.find((o) => o.method === (init?.method ?? "GET") && new RegExp(`^${o.path.replace("{accountId}", "[^/]+")}$`).test(url.pathname));
+ if (!op || op.kind === "list") return Response.json([{}]);
+ const n = Array.isArray(body) ? body.length : op.keys.length === 0 ? 1 : url.searchParams.getAll(op.keys[0]!).length;
+ return Response.json(Array.from({ length: n }, () => ({ ok: true, value: {} })));
+ };
+ return { sent, api: createApi(API_URL, auth, doFetch) };
+}
+
+// Api のメソッドをすべて 1 回ずつ呼ぶ。メソッドを足すとここが型で落ちる(呼び忘れを防ぐ)
+const CALLS: { [K in keyof Api]: (api: Api) => Promise } = {
+ me: (a) => a.me(),
+ account: (a) => a.account("p-1"),
+ createTeam: (a) => a.createTeam("t", "standard"),
+ setExecutionMode: (a) => a.setExecutionMode("p-1", "byok"),
+ addSeat: (a) => a.addSeat("t-1", "u", "standard"),
+ changeSeat: (a) => a.changeSeat("t-1", "u", "premium"),
+ removeSeat: (a) => a.removeSeat("t-1", "u"),
+ installUrl: (a) => a.installUrl("p-1"),
+ authorizeUrl: (a) => a.authorizeUrl("p-1"),
+ repositoryCandidates: (a) => a.repositoryCandidates("p-1", "c", "s"),
+ repositories: (a) => a.repositories("p-1"),
+ registerRepository: (a) => a.registerRepository("p-1", "proof", 1),
+ removeRepository: (a) => a.removeRepository("p-1", 1),
+ modelCredential: (a) => a.modelCredential("p-1"),
+ saveModelCredential: (a) => a.saveModelCredential("p-1", { kind: "anthropic_api_key", api_key: "k" }),
+ deleteModelCredential: (a) => a.deleteModelCredential("p-1"),
+ issueCandidates: (a) => a.issueCandidates("p-1", 1, []),
+ startSprintRun: (a) => a.startSprintRun("p-1", 1, [3]),
+ sprintRuns: (a) => a.sprintRuns("p-1"),
+ sprintRunDetails: (a) => a.sprintRunDetails("p-1", ["s1"]),
+ stepDetails: (a) => a.stepDetails("p-1", "s1", ["k1"]),
+ backlogs: (a) => a.backlogs("p-1", [1]),
+ cancelSprintRun: (a) => a.cancelSprintRun("p-1", "s1"),
+ updateRepository: (a) => a.updateRepository("p-1", 1, null),
+ loops: (a) => a.loops("p-1", 1),
+ startLoop: (a) => a.startLoop("p-1", 1, { name: "l", milestones: ["m"] }),
+ loopDetails: (a) => a.loopDetails("p-1", ["l1"]),
+ updateLoopBudget: (a) => a.updateLoopBudget("p-1", "l1", {}),
+ cancelLoop: (a) => a.cancelLoop("p-1", "l1"),
+ requestNextLap: (a) => a.requestNextLap("p-1", "l1"),
+ answerLoop: (a) => a.answerLoop("p-1", "l1", 3, "approved"),
+};
+
+describe("画面の API クライアントと契約(受け入れ条件 1)", () => {
+ it("どのメソッドの要求も契約の利用者向けの操作に当たり、本文は配列である", async () => {
+ const { sent, api } = recorder();
+ for (const [name, call] of Object.entries(CALLS)) {
+ const before = sent.length;
+ await call(api);
+ expect(sent.length, name).toBeGreaterThan(before);
+ }
+ for (const s of sent) {
+ const op = OPERATIONS.find((o) => o.method === s.method && new RegExp(`^${o.path.replace("{accountId}", "[^/]+")}$`).test(s.path));
+ expect(op, `${s.method} ${s.path}`).toBeDefined();
+ if (s.body !== undefined) expect(Array.isArray(s.body), `${s.method} ${s.path}`).toBe(true);
+ }
+ });
+
+ it("新しい識別子を作る作成には冪等の鍵を付け、呼ぶたびに変える", async () => {
+ const { sent, api } = recorder();
+ await api.startSprintRun("p-1", 1, [3]);
+ await api.startSprintRun("p-1", 1, [3]);
+ const keys = sent.map((s) => (s.body as { idempotency_key: string }[])[0]!.idempotency_key);
+ expect(keys[0]).toMatch(/^[0-9A-Za-z-]{1,64}$/);
+ expect(keys[0]).not.toBe(keys[1]);
+ });
+});
+
+describe("要求の回数(非機能の条件「性能・拡張性」)", () => {
+ it("詳細 3 件は 1 回の要求で読み、上限を超える数は 100 ずつに分ける", async () => {
+ const { sent, api } = recorder();
+ expect(await api.sprintRunDetails("p-1", ["a", "b", "c"])).toHaveLength(3);
+ expect(sent).toHaveLength(1);
+ expect(sent[0]!.query.getAll("sprint_run_id")).toEqual(["a", "b", "c"]);
+ sent.length = 0;
+ await api.loopDetails("p-1", Array.from({ length: MAX_ITEMS + 1 }, (_, i) => `l${i}`));
+ expect(sent.map((s) => s.query.getAll("loop_id").length)).toEqual([MAX_ITEMS, 1]);
+ });
+
+ it("0 件の取得は要求を送らない", async () => {
+ const { sent, api } = recorder();
+ expect(await api.backlogs("p-1", [])).toEqual([]);
+ expect(sent).toHaveLength(0);
+ });
+});
diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts
index bed32de43..a5a9c7927 100644
--- a/factory/web/src/api.ts
+++ b/factory/web/src/api.ts
@@ -1,5 +1,6 @@
import type { Auth } from "./auth.ts";
import type { StepDetailBody } from "@ndf-factory/contracts/views";
+import { MAX_ITEMS, operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations";
// API の応答の形(factory/contracts/openapi/accounts.yaml)
export type Plan = "free" | "pro" | "max" | "team";
@@ -270,20 +271,39 @@ export interface Api {
answerLoop(id: string, loopId: string, issue: number, decision: LoopDecision, pr?: number): Promise<{ pending: LoopPending[] }>;
}
+// 要素の結果(#2384)。入力と同じ順・同じ数で返る
+type ElementResult = { ok: true; value: T } | { ok: false; status: number; error: { code: string; message: string; details?: Record } };
+
+function valueOf(r: ElementResult | undefined): T {
+ if (!r) throw new ApiFailure(503, "error", "");
+ if (r.ok) return r.value;
+ throw new ApiFailure(r.status, r.error.code, r.error.message, r.error.details ?? null);
+}
+
+// 1 回の要求で受け渡す要素の上限ごとに分ける
+function chunks(items: T[]): T[][] {
+ const out: T[][] = [];
+ for (let i = 0; i < items.length; i += MAX_ITEMS) out.push(items.slice(i, i + MAX_ITEMS));
+ return out;
+}
+
+const newKey = () => crypto.randomUUID();
+
+// 要求は操作の表から組み、経路の文字列を直に書かない(#2384 の受け入れ条件 1)。
// 401 では token を消す。画面は ApiFailure の status を見てログインへ移る
export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (...a) => fetch(...a)): Api {
- async function request(method: string, path: string, body?: unknown): Promise {
+ async function request(id: OperationId, accountId: string | null, opts: { query?: Record; items?: unknown[] } = {}): Promise {
const token = auth.getToken();
if (!token) throw new ApiFailure(401, "unauthorized", "unauthorized");
- const res = await doFetch(`${apiUrl}${path}`, {
- method,
+ const op = operation(id);
+ const res = await doFetch(`${apiUrl}${operationUrl(op, accountId, opts.query)}`, {
+ method: op.method,
headers: {
Authorization: `Bearer ${token}`,
- ...(body === undefined ? {} : { "Content-Type": "application/json" }),
+ ...(opts.items === undefined ? {} : { "Content-Type": "application/json" }),
},
- body: body === undefined ? undefined : JSON.stringify(body),
+ body: opts.items === undefined ? undefined : JSON.stringify(opts.items),
});
- if (res.status === 204) return undefined as T;
const data = (await res.json().catch(() => null)) as { error?: { code: string; message: string; details?: Record } } | null;
if (!res.ok) {
if (res.status === 401) auth.clearToken();
@@ -292,54 +312,65 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (.
return data as T;
}
- const seg = encodeURIComponent;
+ // 一覧(条件で集めた値の配列)
+ const list = (id: OperationId, accountId: string | null, query?: Record) => request(id, accountId, { query });
+ // 要素 1 個を送り、その結果の値を返す。失敗の要素は ApiFailure にする
+ const one = async (id: OperationId, accountId: string | null, item: Record) =>
+ valueOf((await request[]>(id, accountId, { items: [item] }))[0]);
+ // 鍵の query で指す取得と削除。要素の結果を入力と同じ順に返す
+ const select = async (id: OperationId, accountId: string | null, key: string, values: (string | number)[], query: Record = {}) => {
+ const out: ElementResult[] = [];
+ for (const part of chunks(values)) out.push(...(await request[]>(id, accountId, { query: { ...query, [key]: part } })));
+ return out;
+ };
+ // 鍵の query で指す要素の値の配列。最初の失敗の要素で投げる
+ const values = async (id: OperationId, accountId: string | null, key: string, ids: (string | number)[], query?: Record) =>
+ (await select(id, accountId, key, ids, query)).map((r) => valueOf(r));
+
return {
- me: () => request("GET", "/me"),
- account: (id) => request("GET", `/accounts/${seg(id)}`),
- createTeam: (name, seat_type) => request("POST", "/teams", { name, seat_type }),
- setExecutionMode: (id, execution_mode) => request("PUT", `/accounts/${seg(id)}/execution-mode`, { execution_mode }),
- addSeat: (id, user_id, seat_type) => request("POST", `/accounts/${seg(id)}/seats`, { user_id, seat_type }),
- changeSeat: (id, userId, seat_type) => request("PATCH", `/accounts/${seg(id)}/seats/${seg(userId)}`, { seat_type }),
- removeSeat: (id, userId) => request("DELETE", `/accounts/${seg(id)}/seats/${seg(userId)}`),
- installUrl: (id) => request("GET", `/accounts/${seg(id)}/github/install-url`),
- authorizeUrl: (id) => request("GET", `/accounts/${seg(id)}/github/authorize-url`),
- repositoryCandidates: (id, code, state) => request("POST", `/accounts/${seg(id)}/github/candidates`, { code, state }),
- repositories: (id) => request("GET", `/accounts/${seg(id)}/repositories`),
- registerRepository: (id, proof, repository_id) => request("POST", `/accounts/${seg(id)}/repositories`, { proof, repository_id }),
- removeRepository: (id, repositoryId) => request("DELETE", `/accounts/${seg(id)}/repositories/${repositoryId}`),
- modelCredential: (id) => request("GET", `/accounts/${seg(id)}/model-credential`),
- saveModelCredential: (id, input) => request("PUT", `/accounts/${seg(id)}/model-credential`, input),
- deleteModelCredential: (id) => request("DELETE", `/accounts/${seg(id)}/model-credential`),
- issueCandidates: (id, repositoryId, exclude) => request("POST", `/accounts/${seg(id)}/repositories/${repositoryId}/issue-candidates`, { exclude }),
- startSprintRun: (id, repository_id, issues) => request("POST", `/accounts/${seg(id)}/sprint-runs`, { repository_id, issues }),
- sprintRuns: (id, repositoryId) =>
- request("GET", `/accounts/${seg(id)}/sprint-runs${repositoryId === undefined ? "" : `?repository_id=${repositoryId}`}`),
- // 経路は 1 件ずつ読む
- sprintRunDetails: (id, sprintRunIds) =>
- Promise.all(sprintRunIds.map((sprintRunId) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}`))),
- stepDetails: (id, sprintRunId, stepKeys) =>
- Promise.all(stepKeys.map((key) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/steps/${seg(key)}`))),
- backlogs: (id, repositoryIds) => Promise.all(repositoryIds.map((repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/backlog`))),
- cancelSprintRun: (id, sprintRunId) => request("POST", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/cancel`),
- updateRepository: (id, repositoryId, max_concurrent_runs) => request("PATCH", `/accounts/${seg(id)}/repositories/${repositoryId}`, { max_concurrent_runs }),
- loops: (id, repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/loops`),
- startLoop: (id, repositoryId, input) => request("POST", `/accounts/${seg(id)}/repositories/${repositoryId}/loops`, input),
- // 経路は 1 件ずつ読む。404 の ID は要素を返さない
+ me: async () => (await list("getMe", null))[0]!,
+ account: async (id) => (await values("getAccount", null, "account_id", [id]))[0]!,
+ createTeam: (name, seat_type) => one("createTeam", null, { name, seat_type, idempotency_key: newKey() }),
+ setExecutionMode: (id, execution_mode) => one("setExecutionMode", null, { account_id: id, execution_mode }),
+ addSeat: (id, user_id, seat_type) => one("addSeat", id, { user_id, seat_type }),
+ changeSeat: (id, user_id, seat_type) => one("changeSeat", id, { user_id, seat_type }),
+ removeSeat: async (id, userId) => {
+ await values("removeSeat", id, "user_id", [userId]);
+ },
+ installUrl: async (id) => (await list<{ url: string }>("getInstallUrl", id))[0]!,
+ authorizeUrl: async (id) => (await list<{ url: string }>("getAuthorizeUrl", id))[0]!,
+ repositoryCandidates: (id, code, state) => one("postRepositoryCandidates", id, { code, state }),
+ repositories: async (id) => ({ repositories: await list("listRepositories", id) }),
+ registerRepository: (id, proof, repository_id) => one("registerRepository", id, { proof, repository_id }),
+ removeRepository: async (id, repositoryId) => {
+ await values("removeRepository", id, "repository_id", [repositoryId]);
+ },
+ modelCredential: async (id) => (await list("getModelCredential", id))[0]!,
+ saveModelCredential: (id, input) => one("putModelCredential", id, input),
+ deleteModelCredential: async (id) => {
+ valueOf((await request[]>("deleteModelCredential", id))[0]);
+ },
+ issueCandidates: (id, repository_id, exclude) => one("postIssueCandidates", id, { repository_id, exclude }),
+ startSprintRun: (id, repository_id, issues) => one("startSprintRun", id, { repository_id, issues, idempotency_key: newKey() }),
+ sprintRuns: async (id, repositoryId) => ({
+ sprint_runs: await list("listSprintRuns", id, repositoryId === undefined ? {} : { repository_id: repositoryId }),
+ }),
+ // 要素の数によらず 1 回の要求で読む。無い実行は他人の実行と同じ forbidden の要素で、そのまま投げる
+ sprintRunDetails: (id, sprintRunIds) => values("getSprintRunDetail", id, "sprint_run_id", sprintRunIds),
+ stepDetails: (id, sprintRunId, stepKeys) => values("getStepDetail", id, "step_key", stepKeys, { sprint_run_id: sprintRunId }),
+ backlogs: (id, repositoryIds) => values("getBacklog", id, "repository_id", repositoryIds),
+ cancelSprintRun: (id, sprint_run_id) => one("cancelSprintRun", id, { sprint_run_id }),
+ updateRepository: (id, repository_id, max_concurrent_runs) => one("updateRepository", id, { repository_id, max_concurrent_runs }),
+ loops: async (id, repositoryId) => ({ loops: await list("listLoops", id, { repository_id: repositoryId }) }),
+ startLoop: (id, repository_id, input) => one("startLoop", id, { repository_id, ...input, idempotency_key: newKey() }),
+ // 要素の数によらず 1 回の要求で読む。無いループ(loop_not_found)の要素は返さない
loopDetails: async (id, loopIds) => {
- const found = await Promise.all(
- loopIds.map((loopId) =>
- request("GET", `/accounts/${seg(id)}/loops/${seg(loopId)}`).catch((err: unknown) => {
- if (err instanceof ApiFailure && err.status === 404) return null;
- throw err;
- }),
- ),
- );
- return found.filter((d): d is LoopDetail => d !== null);
+ const results = await select("getLoop", id, "loop_id", loopIds);
+ return results.filter((r) => r.ok || r.status !== 404).map((r) => valueOf(r));
},
- updateLoopBudget: (id, loopId, budget) => request("PATCH", `/accounts/${seg(id)}/loops/${seg(loopId)}`, { budget }),
- cancelLoop: (id, loopId) => request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/cancel`),
- requestNextLap: (id, loopId) => request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/next-lap`),
- answerLoop: (id, loopId, issue, decision, pr) =>
- request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/answers`, { issue, decision, ...(pr === undefined ? {} : { pr }) }),
+ updateLoopBudget: (id, loop_id, budget) => one("updateLoopBudget", id, { loop_id, budget }),
+ cancelLoop: (id, loop_id) => one("cancelLoop", id, { loop_id }),
+ requestNextLap: (id, loop_id) => one("startNextLap", id, { loop_id }),
+ answerLoop: (id, loop_id, issue, decision, pr) => one("answerLoopPending", id, { loop_id, issue, decision, ...(pr === undefined ? {} : { pr }) }),
};
}
diff --git a/factory/web/src/checks.test.ts b/factory/web/src/checks.test.ts
index 2ab3f86f1..869f57a28 100644
--- a/factory/web/src/checks.test.ts
+++ b/factory/web/src/checks.test.ts
@@ -46,6 +46,15 @@ describe("ダミーの import の検査(受け入れ条件 17)", () => {
});
});
+describe("API への要求の経路の検査(#2384 の受け入れ条件 1)", () => {
+ it("要求の関数へ経路の文字列を直に渡すと落とし、操作の表から組んだ経路は通す", () => {
+ expect(rules("api.ts", `request("GET", "/me");`)).toEqual(["api-path"]);
+ expect(rules("api.ts", "doFetch(`/accounts/${id}/sprint-runs`);")).toEqual(["api-path"]);
+ expect(rules("api.ts", "doFetch(`${apiUrl}${operationUrl(op, id)}`);")).toEqual([]);
+ expect(rules("pages/A.tsx", `navigate("/accounts/a/sprints");`)).toEqual([]);
+ });
+});
+
describe("今の源", () => {
it("検査で何も見つからない", () => {
expect(checkTree(fileURLToPath(new URL(".", import.meta.url)))).toEqual([]);
From 5fce25dcd92954f84c1a587423edc939379bee3e Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 22:40:07 +0000
Subject: [PATCH 03/16] =?UTF-8?q?=E5=A7=94=E4=BB=BB=E3=81=AE=E8=B3=87?=
=?UTF-8?q?=E6=A0=BC=E6=83=85=E5=A0=B1=E3=82=92=E7=99=BA=E8=A1=8C=E3=83=BB?=
=?UTF-8?q?=E7=85=A7=E5=90=88=E3=83=BB=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=97?=
=?UTF-8?q?=E3=80=81=E4=BA=BA=E3=81=A0=E3=81=91=E3=81=8C=E7=A2=BA=E5=AE=9A?=
=?UTF-8?q?=E3=81=A7=E3=81=8D=E3=82=8B=E6=93=8D=E4=BD=9C=E3=82=92=E5=A7=94?=
=?UTF-8?q?=E4=BB=BB=E3=81=8B=E3=82=89=E6=96=AD=E3=82=8B=EF=BC=88#2384?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
委任の資格情報(ndfd__<秘密>)を人が画面で発行し、秘密は SHA-256 だけを保存する。
要求のたびに表を強い整合で読み、期限切れと取り消し済みは 401 にする。操作の登録の層が、
扱い human の操作を範囲によらず 403 human_only、読み取りだけの範囲の書き込みを
403 scope_insufficient で断る。委任の画面(/delegations)を足し、画面の地図に行を足した。
Co-Authored-By: Claude Opus 5.5
---
docs/specifications/ndf-factory-screen-map.md | 2 +
factory/api/src/accounts/model.ts | 6 +
factory/api/src/auth/resolve.ts | 32 ++-
.../api/src/delegations/delegations.test.ts | 192 ++++++++++++++++++
factory/api/src/delegations/memory-store.ts | 45 ++++
factory/api/src/delegations/model.ts | 47 +++++
factory/api/src/delegations/service.ts | 148 ++++++++++++++
factory/api/src/delegations/store.ts | 129 ++++++++++++
factory/api/src/errors.ts | 14 ++
factory/api/src/handlers/api.ts | 6 +
factory/api/src/routes/delegations.ts | 19 ++
factory/api/src/routes/operation.ts | 11 +-
factory/api/src/testing/app.ts | 66 +++++-
factory/web/src/App.tsx | 2 +
factory/web/src/api.test.ts | 3 +
factory/web/src/api.ts | 27 +++
factory/web/src/context.tsx | 2 +
factory/web/src/dummy/api.ts | 25 ++-
factory/web/src/dummy/data.ts | 15 +-
factory/web/src/i18n/ja.json | 30 ++-
factory/web/src/main.tsx | 2 +-
factory/web/src/pages/AccountPage.tsx | 3 +
factory/web/src/pages/DelegationsPage.tsx | 161 +++++++++++++++
factory/web/src/pages/delegations.test.tsx | 54 +++++
factory/web/src/screens.ts | 5 +-
25 files changed, 1037 insertions(+), 9 deletions(-)
create mode 100644 factory/api/src/delegations/delegations.test.ts
create mode 100644 factory/api/src/delegations/memory-store.ts
create mode 100644 factory/api/src/delegations/model.ts
create mode 100644 factory/api/src/delegations/service.ts
create mode 100644 factory/api/src/delegations/store.ts
create mode 100644 factory/api/src/routes/delegations.ts
create mode 100644 factory/web/src/pages/DelegationsPage.tsx
create mode 100644 factory/web/src/pages/delegations.test.tsx
diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md
index 67817d9cc..0a26ac8ae 100644
--- a/docs/specifications/ndf-factory-screen-map.md
+++ b/docs/specifications/ndf-factory-screen-map.md
@@ -40,6 +40,7 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ
| `/accounts/:accountId/repositories/:repositoryId/loops` | ループ | 個人・管理者・席 | あり | 開発ループの一覧(名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由)・始める |
| `/accounts/:accountId/loops/:loopId` | ループの詳細 | 個人・管理者・席 | あり | 周回・周回の実行と区間の数・利用上限の再開の時刻・人の判断待ちと答え・予算の変更・止める・次の周回を始める |
| `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | あり | 順位の表と棚卸しの記録(読むだけ) |
+| `/delegations` | エージェントへの委任 | 個人・管理者・席 | あり | 委任の資格情報の発行(値は発行の直後に 1 度だけ出す)・一覧(範囲・期限・状態)・取り消し。オフラインの間は発行と取り消しをしない。自分のアカウントからたどる |
| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 |
| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 |
| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 |
@@ -53,6 +54,7 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ
| 役割 | 主な動線 |
| --- | --- |
| 個人 | ログイン → 自分のアカウント → リポジトリ → スプリントを始める → スプリントの一覧 → スプリントの詳細 |
+| エージェントに任せる人(個人・管理者・席) | 自分のアカウント → エージェントへの委任(発行して値を MCP のクライアントへ写す・取り消す) |
| 開発ループを回す人(個人・管理者・席) | リポジトリ → ループ(始める)→ ループの詳細(人の判断待ちに答える・次の周回を始める)→ スプリントの詳細 |
| Team の管理者 | ログイン → 自分のアカウント → チームのアカウント(席の管理・実行の形態)→ キーの持ち込み・リポジトリ |
| 席 | ログイン → 自分のアカウント → チームのアカウント → リポジトリ → スプリントを始める → スプリントの一覧 |
diff --git a/factory/api/src/accounts/model.ts b/factory/api/src/accounts/model.ts
index b8db8392d..36ad453ad 100644
--- a/factory/api/src/accounts/model.ts
+++ b/factory/api/src/accounts/model.ts
@@ -17,9 +17,15 @@ export const PERSONAL_PLANS: readonly Plan[] = ["free", "pro", "max"];
// 新しいアカウントの実行の形態(I24)
export const INITIAL_EXECUTION_MODE: ExecutionMode = "local";
+// 呼び出し元の種類(#2384)。Cognito の token は screen、委任の資格情報の直の要求は api、MCP の受け口の内部の要求は mcp
+export type Via = "screen" | "api" | "mcp";
+
export interface Caller {
userId: string;
idp: Idp;
+ // 委任の資格情報で呼んだときだけ。認可は持たず、人の直の操作と同じ解決を通す(I6)
+ delegation?: { credentialId: string; scope: "read" | "read_write" } | null;
+ via?: Via;
}
export interface UserRecord {
diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts
index 297f218c1..f374eda8a 100644
--- a/factory/api/src/auth/resolve.ts
+++ b/factory/api/src/auth/resolve.ts
@@ -1,11 +1,41 @@
import type { Context, MiddlewareHandler } from "hono";
import type { AccountService } from "../accounts/service.ts";
import type { AccountContext, Caller } from "../accounts/model.ts";
+import type { DelegationService } from "../delegations/service.ts";
+import { AuthError } from "../errors.ts";
import { verifyAuthorization, type AccessTokenVerifier } from "./verify.ts";
export interface AuthDeps {
verifier: AccessTokenVerifier;
accounts: AccountService;
+ // 委任の資格情報の照合(#2384)。無ければ委任の資格情報は 401
+ delegations?: DelegationService | null;
+}
+
+// MCP の受け口が内部の要求に付ける印(app.request の第 3 引数の環境に置く)。外から届くヘッダーでは決めない(決定 13・I16)
+export const MCP_INTERNAL = Symbol("ndf-factory.mcp-internal");
+
+export function isMcpInternal(env: unknown): boolean {
+ return typeof env === "object" && env !== null && (env as Record)[MCP_INTERNAL] === true;
+}
+
+const DELEGATION_PREFIX = "Bearer ndfd_";
+
+// Authorization の値を、委任の資格情報(頭が ndfd_)か Cognito の access token として検証する(E2)。どの失敗も 401
+export async function verifyCaller(deps: AuthDeps, authorization: string | undefined, mcp: boolean): Promise {
+ if (authorization?.startsWith(DELEGATION_PREFIX)) {
+ const verified = deps.delegations ? await deps.delegations.verify(authorization.slice("Bearer ".length)) : null;
+ if (!verified) throw new AuthError();
+ return {
+ userId: verified.userId,
+ idp: verified.idp,
+ delegation: { credentialId: verified.credentialId, scope: verified.scope },
+ via: mcp ? "mcp" : "api",
+ };
+ }
+ // MCP の受け口は委任の資格情報だけを受ける
+ if (mcp) throw new AuthError();
+ return { ...(await verifyAuthorization(deps.verifier, authorization)), delegation: null, via: "screen" };
}
// 操作の登録の層が、操作の記録へ渡すために置く値(#2384)。対象の ID は許可した鍵の値だけ
@@ -25,7 +55,7 @@ export function requireCaller(deps: () => AuthDeps): MiddlewareHandler
return async (c, next) => {
const d = deps();
c.set("auth", d);
- c.set("caller", await verifyAuthorization(d.verifier, c.req.header("Authorization")));
+ c.set("caller", await verifyCaller(d, c.req.header("Authorization"), isMcpInternal(c.env)));
await next();
};
}
diff --git a/factory/api/src/delegations/delegations.test.ts b/factory/api/src/delegations/delegations.test.ts
new file mode 100644
index 000000000..6972288f8
--- /dev/null
+++ b/factory/api/src/delegations/delegations.test.ts
@@ -0,0 +1,192 @@
+// 委任の資格情報と、人だけが確定できる操作(#2384 の受け入れ条件 10〜14・17・18・26、不変条件 I1〜I6・I14)
+import { createHash } from "node:crypto";
+import { beforeEach, describe, expect, it } from "vitest";
+import { OPERATIONS, operationUrl, type OperationId } from "@ndf-factory/contracts/operations";
+import { createTestApi, firstResult, type OpOptions, type TestApi } from "../testing/app.ts";
+
+let api: TestApi;
+let keys = 0;
+beforeEach(async () => {
+ api = await createTestApi({ factory: true });
+});
+
+async function personal(user: string): Promise {
+ return (await (await api.op("getMe", { user })).json())[0].personal_account_id;
+}
+
+async function issue(user: string, scope: "read" | "read_write", expires_in_seconds = 3600) {
+ const { status, body } = await firstResult(
+ await api.op("createDelegation", { user, items: [{ client_name: "手元の Claude Code", scope, expires_in_seconds, idempotency_key: `d-${++keys}` }] }),
+ );
+ expect(status).toBe(200);
+ return body as { delegation: { credential_id: string; expires_at: string }; credential: string };
+}
+
+const as = (credential: string) => ({ authorization: `Bearer ${credential}` });
+const agent = (credential: string, id: OperationId, opts: OpOptions = {}) => api.op(id, { ...opts, ...as(credential) });
+
+describe("発行(I1・I3・I14)", () => {
+ it("値は ndfd__<秘密> で 1 度だけ返り、保存した行に平文が無く、ハッシュが値と一致する", async () => {
+ await personal("u-1");
+ const { delegation, credential } = await issue("u-1", "read");
+ const m = /^ndfd_([a-z2-7]{26})_(.+)$/.exec(credential)!;
+ expect(m[1]).toBe(delegation.credential_id);
+ const row = api.delegationStore.records.get(delegation.credential_id)!;
+ expect(JSON.stringify(row)).not.toContain(m[2]);
+ expect(row.secretHash).toBe(createHash("sha256").update(m[2]!).digest("hex"));
+ const list = await (await api.op("listDelegations", { user: "u-1" })).json();
+ expect(list).toEqual([expect.objectContaining({ credential_id: delegation.credential_id, scope: "read", state: "active", revoked_at: null })]);
+ expect(JSON.stringify(list)).not.toContain(m[2]);
+ });
+
+ it.each([299, 3601])("期限 %i 秒は 400 で保存しない", async (seconds) => {
+ await personal("u-1");
+ const res = await firstResult(await api.op("createDelegation", { user: "u-1", items: [{ client_name: "x", scope: "read", expires_in_seconds: seconds, idempotency_key: "k" }] }));
+ expect(res.status).toBe(400);
+ expect(api.delegationStore.records.size).toBe(0);
+ });
+
+ it("同じ冪等の鍵の 2 回目は同じ資格情報を返し、値を返さない", async () => {
+ await personal("u-1");
+ const item = { client_name: "x", scope: "read", expires_in_seconds: 600, idempotency_key: "same" };
+ const first = await firstResult(await api.op("createDelegation", { user: "u-1", items: [item] }));
+ const second = await firstResult(await api.op("createDelegation", { user: "u-1", items: [item] }));
+ expect(second.body.delegation.credential_id).toBe(first.body.delegation.credential_id);
+ expect(first.body.credential).toMatch(/^ndfd_/);
+ expect(second.body.credential).toBeNull();
+ expect(api.delegationStore.records.size).toBe(1);
+ });
+});
+
+describe("委任の資格情報での認証(受け入れ条件 10〜12、I2・I6)", () => {
+ it("人の token と同じ人の委任で、同じ操作の許可と拒否が一致する", async () => {
+ const own = await personal("u-1");
+ const other = await personal("u-2");
+ const { credential } = await issue("u-1", "read_write");
+ const cases: [OperationId, OpOptions][] = [
+ ["getAccount", { query: { account_id: [own] } }],
+ ["getAccount", { query: { account_id: [other] } }],
+ ["listSprintRuns", { accountId: own }],
+ ["listSprintRuns", { accountId: other }],
+ ["setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] }],
+ ["setExecutionMode", { items: [{ account_id: other, execution_mode: "byok" }] }],
+ ];
+ for (const [id, opts] of cases) {
+ const human = await firstResult(await api.op(id, { ...opts, user: "u-1" }));
+ const delegated = await firstResult(await agent(credential, id, opts));
+ expect(delegated.status, `${id} ${JSON.stringify(opts)}`).toBe(human.status);
+ }
+ });
+
+ it("期限を過ぎた資格情報は 401 で、操作を行わない", async () => {
+ const own = await personal("u-1");
+ const { credential } = await issue("u-1", "read_write", 300);
+ expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200);
+ api.clock.now = new Date(api.clock.now.getTime() + 300_000);
+ const res = await agent(credential, "setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] });
+ expect(res.status).toBe(401);
+ expect(api.store.accounts.get(own)?.executionMode).toBe("local");
+ });
+
+ it("取り消しの後の最初の要求が 401 で、照合の結果をためない", async () => {
+ const own = await personal("u-1");
+ const { delegation, credential } = await issue("u-1", "read");
+ const reads = api.delegationStore.reads;
+ expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200);
+ expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200);
+ expect(api.delegationStore.reads - reads).toBe(2);
+ const revoked = await firstResult(await api.op("revokeDelegation", { user: "u-1", query: { credential_id: [delegation.credential_id] } }));
+ expect(revoked.body.state).toBe("revoked");
+ expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(401);
+ });
+
+ it("形の違う値・秘密の違う値・無い ID は 401", async () => {
+ await personal("u-1");
+ const { credential } = await issue("u-1", "read");
+ const wrongSecret = credential.slice(0, -1) + (credential.endsWith("A") ? "B" : "A");
+ for (const value of ["ndfd_x", wrongSecret, `ndfd_${"a".repeat(26)}_${"b".repeat(43)}`]) {
+ expect((await api.op("getMe", as(value))).status, value).toBe(401);
+ }
+ });
+
+ it("委任を使わない人の操作は、今と同じく token の検証の失敗が 401、アカウントの解決の失敗が 403(受け入れ条件 26)", async () => {
+ expect((await api.op("getMe", { authorization: "Bearer broken" })).status).toBe(401);
+ await personal("u-1");
+ expect((await api.op("listSprintRuns", { user: "u-1", accountId: "00000000-0000-4000-8000-0000000000ff" })).status).toBe(403);
+ });
+});
+
+describe("範囲と人だけが確定できる操作(受け入れ条件 13・14・16〜18、I4・I5)", () => {
+ it("読み取りだけの範囲で書き込みの操作は 403 scope_insufficient で、操作を行わない", async () => {
+ const own = await personal("u-1");
+ const { credential } = await issue("u-1", "read");
+ const res = await agent(credential, "setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] });
+ expect(res.status).toBe(403);
+ expect((await res.json()).error.code).toBe("scope_insufficient");
+ expect(api.store.accounts.get(own)?.executionMode).toBe("local");
+ });
+
+ it("委任の資格情報で委任の資格情報の発行を呼ぶと 403", async () => {
+ await personal("u-1");
+ const { credential } = await issue("u-1", "read_write");
+ const res = await agent(credential, "createDelegation", { items: [{ client_name: "y", scope: "read_write", expires_in_seconds: 600, idempotency_key: "z" }] });
+ expect(res.status).toBe(403);
+ expect((await res.json()).error.code).toBe("human_only");
+ expect(api.delegationStore.records.size).toBe(1);
+ });
+
+ it("扱い human の操作をすべて読み書きの範囲で呼ぶと 403 human_only で、何も保存されない", async () => {
+ const own = await personal("u-1");
+ const { delegation, credential } = await issue("u-1", "read_write");
+ const snapshot = () =>
+ JSON.stringify([
+ [...api.store.accounts.values()],
+ [...api.store.seats.values()],
+ api.store.history.length,
+ [...api.delegationStore.records.values()],
+ [...api.h!.table.loops.values()],
+ [...api.h!.table.credentials.values()],
+ [...api.secrets.values.values()],
+ ]);
+ const before = snapshot();
+ const human = OPERATIONS.filter((op) => op.access === "human");
+ expect(human).toHaveLength(10);
+ for (const op of human) {
+ const query = Object.fromEntries(op.keys.length && (op.kind === "select" || op.kind === "delete") ? [[op.keys[0]!, ["x"]]] : []);
+ if (op.operationId === "revokeDelegation") query.credential_id = [delegation.credential_id];
+ const item = { user_id: "u-2", seat_type: "premium", name: "t", kind: "anthropic_api_key", api_key: "sk-SECRET", loop_id: "l", issue: 1, decision: "approved", repository_id: 1, seq: 1, choice: "reject", client_name: "c", scope: "read", expires_in_seconds: 600, idempotency_key: "h" };
+ const hasBody = !["select", "delete"].includes(op.kind);
+ const res = await api.call(op.method, operationUrl(op, own, query), { ...as(credential), body: hasBody ? [item] : undefined });
+ expect(res.status, op.operationId).toBe(403);
+ expect((await res.json()).error.code, op.operationId).toBe("human_only");
+ }
+ expect(snapshot()).toBe(before);
+ });
+
+ it("委任の資格情報で、スプリントの詳細と承認待ちの一覧は読める", async () => {
+ const own = await personal("u-1");
+ const { credential } = await issue("u-1", "read");
+ const h = api.h!;
+ h.table.registrations.set(`${own}#101`, { accountId: own, repositoryId: 101, fullName: "o/r", installationId: 9, registeredAt: "2026-11-04T00:00:00.000Z" } as never);
+ const list = await firstResult(await agent(credential, "listRepositoryApprovalRequests", { accountId: own, query: { repository_id: [101] } }));
+ expect(list.status).toBe(200);
+ expect(list.body.items).toEqual([]);
+ const detail = await agent(credential, "getSprintRunDetail", { accountId: own, query: { sprint_run_id: ["sr-none"] } });
+ expect(detail.status).toBe(200);
+ expect((await detail.json())[0].error.code).toBe("forbidden");
+ });
+});
+
+describe("取り消し(E9)", () => {
+ it("他人の資格情報は forbidden、期限切れの資格情報は何も変えずに成功", async () => {
+ await personal("u-1");
+ await personal("u-2");
+ const { delegation } = await issue("u-1", "read", 300);
+ const others = await firstResult(await api.op("revokeDelegation", { user: "u-2", query: { credential_id: [delegation.credential_id] } }));
+ expect(others.status).toBe(403);
+ api.clock.now = new Date(api.clock.now.getTime() + 301_000);
+ const expired = await firstResult(await api.op("revokeDelegation", { user: "u-1", query: { credential_id: [delegation.credential_id] } }));
+ expect(expired.body).toMatchObject({ state: "expired", revoked_at: null });
+ expect(api.delegationStore.records.get(delegation.credential_id)?.revokedAt).toBeNull();
+ });
+});
diff --git a/factory/api/src/delegations/memory-store.ts b/factory/api/src/delegations/memory-store.ts
new file mode 100644
index 000000000..f4b4536a8
--- /dev/null
+++ b/factory/api/src/delegations/memory-store.ts
@@ -0,0 +1,45 @@
+// DynamoDelegationStore と同じ条件で書く、メモリの上の保存。テストと手元の確かめに使う
+import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts";
+import type { DelegationRecord } from "./model.ts";
+import type { DelegationStore } from "./store.ts";
+
+export class MemoryDelegationStore implements DelegationStore {
+ readonly records = new Map();
+ readonly idempotency = new Map();
+ // 読み出しの回数(取り消しを次の要求から効かせるため、照合をためないことを確かめる)
+ reads = 0;
+
+ async get(credentialId: string) {
+ this.reads += 1;
+ const r = this.records.get(credentialId);
+ return r ? { ...r } : null;
+ }
+
+ async create(record: DelegationRecord, idempotency?: IdempotencyClaim) {
+ if (this.records.has(record.credentialId)) throw new Error("condition failed");
+ if (idempotency) {
+ const row = this.idempotency.get(idempotencyPk(idempotency));
+ if (row && row.ttl >= epochSeconds(idempotency.createdAt)) return "duplicate" as const;
+ this.idempotency.set(idempotencyPk(idempotency), { ...idempotency });
+ }
+ this.records.set(record.credentialId, { ...record });
+ return "created" as const;
+ }
+
+ async listByUser(userId: string, limit: number) {
+ return [...this.records.values()]
+ .filter((r) => r.userId === userId)
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
+ .slice(0, limit)
+ .map((r) => ({ ...r }));
+ }
+
+ async revoke(credentialId: string, at: string) {
+ const r = this.records.get(credentialId);
+ if (r && r.revokedAt === null) r.revokedAt = at;
+ }
+
+ async getIdempotent(ref: IdempotencyRef, now: string) {
+ return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now);
+ }
+}
diff --git a/factory/api/src/delegations/model.ts b/factory/api/src/delegations/model.ts
new file mode 100644
index 000000000..ee1582358
--- /dev/null
+++ b/factory/api/src/delegations/model.ts
@@ -0,0 +1,47 @@
+// 委任の資格情報(#2384 の集約「委任の資格情報」)。人が画面で発行し、エージェントかクライアントへ渡す
+import type { Idp } from "../accounts/model.ts";
+
+// read は読み取りだけ、read_write は読み書き。どちらも人だけが確定できる操作は呼べない(I4)
+export type DelegationScope = "read" | "read_write";
+export type DelegationState = "active" | "expired" | "revoked";
+
+export const DELEGATION_SCOPES: readonly DelegationScope[] = ["read", "read_write"];
+// 期限は発行から 300〜3,600 秒(I1)
+export const MIN_EXPIRES_SECONDS = 300;
+export const MAX_EXPIRES_SECONDS = 3600;
+export const MAX_CLIENT_NAME = 64;
+// 一覧で返す数
+export const LIST_LIMIT = 100;
+// 値の頭。Cognito の token と分ける(決定 8)
+export const CREDENTIAL_PREFIX = "ndfd_";
+
+export interface DelegationRecord {
+ credentialId: string;
+ userId: string;
+ idp: Idp;
+ clientName: string;
+ scope: DelegationScope;
+ // 秘密の SHA-256 の 16 進。平文は持たない(I3)
+ secretHash: string;
+ createdAt: string;
+ expiresAt: string;
+ // null は取り消していない。取り消しで 1 度だけ書く
+ revokedAt: string | null;
+}
+
+export function stateOf(record: DelegationRecord, now: string): DelegationState {
+ if (record.revokedAt !== null) return "revoked";
+ return record.expiresAt <= now ? "expired" : "active";
+}
+
+export function delegationView(record: DelegationRecord, now: string) {
+ return {
+ credential_id: record.credentialId,
+ client_name: record.clientName,
+ scope: record.scope,
+ created_at: record.createdAt,
+ expires_at: record.expiresAt,
+ revoked_at: record.revokedAt,
+ state: stateOf(record, now),
+ };
+}
diff --git a/factory/api/src/delegations/service.ts b/factory/api/src/delegations/service.ts
new file mode 100644
index 000000000..7d38239c6
--- /dev/null
+++ b/factory/api/src/delegations/service.ts
@@ -0,0 +1,148 @@
+// 委任の管理(#2384 の F2・F3)。委任の資格情報の発行・一覧・取り消しと、値の照合。
+// 値は ndfd_<資格情報の ID>_<秘密> の不透明な値で、要求のたびに表を強い整合で読み、照合の結果をためない(決定 8)
+import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
+import type { Caller, Idp } from "../accounts/model.ts";
+import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.ts";
+import { claimOf, parseIdempotencyKey, type IdempotencyRef } from "../idempotency.ts";
+import {
+ CREDENTIAL_PREFIX,
+ DELEGATION_SCOPES,
+ LIST_LIMIT,
+ MAX_CLIENT_NAME,
+ MAX_EXPIRES_SECONDS,
+ MIN_EXPIRES_SECONDS,
+ delegationView,
+ stateOf,
+ type DelegationRecord,
+ type DelegationScope,
+} from "./model.ts";
+import type { DelegationStore } from "./store.ts";
+
+// RFC 4648 の base32 を小文字で、埋めの = を付けずに書く。16 バイトは 26 字
+const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
+function base32(bytes: Buffer): string {
+ let bits = 0;
+ let value = 0;
+ let out = "";
+ for (const b of bytes) {
+ value = (value << 8) | b;
+ bits += 8;
+ while (bits >= 5) {
+ out += BASE32[(value >>> (bits - 5)) & 31];
+ bits -= 5;
+ }
+ }
+ if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
+ return out;
+}
+
+const VALUE = /^ndfd_([a-z2-7]{26})_([A-Za-z0-9_-]{43})$/;
+const sha256 = (secret: string) => createHash("sha256").update(secret, "utf8").digest("hex");
+
+// 照合が通った委任の資格情報。人の直の操作と同じ Caller を組むための値と、範囲を持つ
+export interface VerifiedDelegation {
+ userId: string;
+ idp: Idp;
+ credentialId: string;
+ scope: DelegationScope;
+}
+
+export interface DelegationDeps {
+ store: DelegationStore;
+ now?: () => Date;
+ random?: (n: number) => Buffer;
+}
+
+export class DelegationService {
+ private readonly now: () => Date;
+ private readonly random: (n: number) => Buffer;
+
+ constructor(private readonly deps: DelegationDeps) {
+ this.now = deps.now ?? (() => new Date());
+ this.random = deps.random ?? randomBytes;
+ }
+
+ private iso(): string {
+ return this.now().toISOString();
+ }
+
+ // 値の照合(E2・E10)。形が違う・無い・秘密が違う・期限切れ・取り消し済みはどれも null(呼ぶ側が 401 にする)
+ async verify(value: string): Promise {
+ const m = VALUE.exec(value);
+ if (!m) return null;
+ let record: DelegationRecord | null;
+ try {
+ record = await this.deps.store.get(m[1]!);
+ } catch {
+ throw new ServiceUnavailableError("delegation_unavailable");
+ }
+ if (!record) return null;
+ const given = Buffer.from(sha256(m[2]!), "hex");
+ const saved = Buffer.from(record.secretHash, "hex");
+ if (given.length !== saved.length || !timingSafeEqual(given, saved)) return null;
+ if (stateOf(record, this.iso()) !== "active") return null;
+ return { userId: record.userId, idp: record.idp, credentialId: record.credentialId, scope: record.scope };
+ }
+
+ // 発行(E1)。値は結果で 1 度だけ返す。同じ冪等の鍵の 2 回目は同じ資格情報を返し、値を null にする(I14)
+ async issue(caller: Caller, element: Record) {
+ const clientName = element.client_name;
+ if (typeof clientName !== "string" || clientName.trim() === "" || clientName.length > MAX_CLIENT_NAME) {
+ throw new InvalidRequestError(`クライアントの名前は 1〜${MAX_CLIENT_NAME} 字にしてください`);
+ }
+ const scope = element.scope;
+ if (!DELEGATION_SCOPES.includes(scope as DelegationScope)) throw new InvalidRequestError("範囲は read か read_write です");
+ const seconds = element.expires_in_seconds;
+ if (!Number.isInteger(seconds) || (seconds as number) < MIN_EXPIRES_SECONDS || (seconds as number) > MAX_EXPIRES_SECONDS) {
+ throw new InvalidRequestError(`期限は ${MIN_EXPIRES_SECONDS}〜${MAX_EXPIRES_SECONDS} 秒にしてください`, "invalid_expires_in");
+ }
+ const ref: IdempotencyRef = { userId: caller.userId, operationId: "createDelegation", key: parseIdempotencyKey(element.idempotency_key) };
+ const now = this.now();
+ const replayed = await this.replay(ref, now.toISOString());
+ if (replayed) return replayed;
+ const credentialId = base32(this.random(16));
+ const secret = this.random(32).toString("base64url");
+ const record: DelegationRecord = {
+ credentialId,
+ userId: caller.userId,
+ idp: caller.idp,
+ clientName,
+ scope: scope as DelegationScope,
+ secretHash: sha256(secret),
+ createdAt: now.toISOString(),
+ expiresAt: new Date(now.getTime() + (seconds as number) * 1000).toISOString(),
+ revokedAt: null,
+ };
+ const created = await this.deps.store.create(record, claimOf(ref, credentialId, record.createdAt));
+ if (created === "duplicate") {
+ const won = await this.replay(ref, now.toISOString());
+ if (!won) throw new ServiceUnavailableError("delegation_unavailable");
+ return won;
+ }
+ return { delegation: delegationView(record, record.createdAt), credential: `${CREDENTIAL_PREFIX}${credentialId}_${secret}` };
+ }
+
+ private async replay(ref: IdempotencyRef, now: string) {
+ const id = await this.deps.store.getIdempotent(ref, now);
+ const record = id ? await this.deps.store.get(id) : null;
+ return record ? { delegation: delegationView(record, now), credential: null } : null;
+ }
+
+ // 自分が発行したものだけを、新しい順で返す
+ async list(caller: Caller) {
+ const now = this.iso();
+ return (await this.deps.store.listByUser(caller.userId, LIST_LIMIT)).map((r) => delegationView(r, now));
+ }
+
+ // 取り消し(E9)。期限切れと取り消し済みは何も変えずに成功を返す。他人の資格情報と無い資格情報は同じ forbidden
+ async revoke(caller: Caller, credentialId: unknown) {
+ const record = typeof credentialId === "string" ? await this.deps.store.get(credentialId) : null;
+ if (!record || record.userId !== caller.userId) throw new ForbiddenError();
+ const now = this.iso();
+ if (stateOf(record, now) === "active") {
+ await this.deps.store.revoke(record.credentialId, now);
+ return delegationView({ ...record, revokedAt: now }, now);
+ }
+ return delegationView(record, now);
+ }
+}
diff --git a/factory/api/src/delegations/store.ts b/factory/api/src/delegations/store.ts
new file mode 100644
index 000000000..504fd4249
--- /dev/null
+++ b/factory/api/src/delegations/store.ts
@@ -0,0 +1,129 @@
+// 委任の資格情報の表への読み書き(#2384 の「データ構造」)。区分の鍵は credential_id だけで、利用者の一覧は GSI で引く
+import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
+import { DynamoDBDocumentClient, GetCommand, QueryCommand, TransactWriteCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
+import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts";
+import type { DelegationRecord } from "./model.ts";
+
+export interface DelegationStore {
+ // 強い整合で読む(取り消しを次の要求から効かせる。I2)
+ get(credentialId: string): Promise;
+ // 行が無いときだけ書く。idempotency があれば鍵の行も同じトランザクションで書き、期限の中の同じ鍵があれば duplicate
+ create(record: DelegationRecord, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate">;
+ // 利用者が発行した資格情報の新しい順
+ listByUser(userId: string, limit: number): Promise;
+ // revokedAt が空のときだけ 1 度だけ書く。既に書いてあれば何もしない
+ revoke(credentialId: string, at: string): Promise;
+ getIdempotent(ref: IdempotencyRef, now: string): Promise;
+}
+
+// GSI の名前(インフラが作る)
+export const USER_INDEX = "user_id-created_at";
+
+type Item = Record;
+
+function toItem(r: DelegationRecord): Item {
+ return {
+ credential_id: r.credentialId,
+ user_id: r.userId,
+ idp: r.idp,
+ client_name: r.clientName,
+ scope: r.scope,
+ secret_hash: r.secretHash,
+ created_at: r.createdAt,
+ expires_at: r.expiresAt,
+ ...(r.revokedAt === null ? {} : { revoked_at: r.revokedAt }),
+ };
+}
+
+function toRecord(i: Item): DelegationRecord {
+ return {
+ credentialId: String(i.credential_id),
+ userId: String(i.user_id),
+ idp: i.idp as DelegationRecord["idp"],
+ clientName: String(i.client_name),
+ scope: i.scope as DelegationRecord["scope"],
+ secretHash: String(i.secret_hash),
+ createdAt: String(i.created_at),
+ expiresAt: String(i.expires_at),
+ revokedAt: typeof i.revoked_at === "string" ? i.revoked_at : null,
+ };
+}
+
+const isCancelled = (err: unknown) => err instanceof Error && err.name === "TransactionCanceledException";
+const isConditionFailed = (err: unknown) => err instanceof Error && err.name === "ConditionalCheckFailedException";
+
+export class DynamoDelegationStore implements DelegationStore {
+ private readonly doc: DynamoDBDocumentClient;
+
+ constructor(
+ private readonly tableName: string,
+ client: DynamoDBClient = new DynamoDBClient({}),
+ ) {
+ this.doc = DynamoDBDocumentClient.from(client);
+ }
+
+ async get(credentialId: string) {
+ const out = await this.doc.send(new GetCommand({ TableName: this.tableName, Key: { credential_id: credentialId }, ConsistentRead: true }));
+ const item = out.Item as Item | undefined;
+ return item && typeof item.secret_hash === "string" ? toRecord(item) : null;
+ }
+
+ async create(record: DelegationRecord, idempotency?: IdempotencyClaim) {
+ const items: Item[] = [{ Put: { TableName: this.tableName, Item: toItem(record), ConditionExpression: "attribute_not_exists(credential_id)" } }];
+ if (idempotency) {
+ items.push({
+ Put: {
+ TableName: this.tableName,
+ Item: { credential_id: idempotencyPk(idempotency), createdId: idempotency.createdId, createdAt: idempotency.createdAt, ttl: idempotency.ttl },
+ ConditionExpression: "attribute_not_exists(credential_id) OR #ttl < :now",
+ ExpressionAttributeNames: { "#ttl": "ttl" },
+ ExpressionAttributeValues: { ":now": epochSeconds(idempotency.createdAt) },
+ },
+ });
+ }
+ try {
+ await this.doc.send(new TransactWriteCommand({ TransactItems: items as never }));
+ return "created" as const;
+ } catch (err) {
+ // 資格情報の ID は 16 バイトの乱数で重ならないため、条件の失敗は冪等の鍵の行
+ if (idempotency && isCancelled(err)) return "duplicate" as const;
+ throw err;
+ }
+ }
+
+ async listByUser(userId: string, limit: number) {
+ const out = await this.doc.send(
+ new QueryCommand({
+ TableName: this.tableName,
+ IndexName: USER_INDEX,
+ KeyConditionExpression: "user_id = :u",
+ ExpressionAttributeValues: { ":u": userId },
+ ScanIndexForward: false,
+ Limit: limit,
+ }),
+ );
+ return (out.Items ?? []).map((i) => toRecord(i as Item));
+ }
+
+ async revoke(credentialId: string, at: string) {
+ try {
+ await this.doc.send(
+ new UpdateCommand({
+ TableName: this.tableName,
+ Key: { credential_id: credentialId },
+ UpdateExpression: "SET revoked_at = :at",
+ ConditionExpression: "attribute_exists(credential_id) AND attribute_not_exists(revoked_at)",
+ ExpressionAttributeValues: { ":at": at },
+ }),
+ );
+ } catch (err) {
+ if (!isConditionFailed(err)) throw err;
+ }
+ }
+
+ async getIdempotent(ref: IdempotencyRef, now: string) {
+ const out = await this.doc.send(new GetCommand({ TableName: this.tableName, Key: { credential_id: idempotencyPk(ref) }, ConsistentRead: true }));
+ const item = out.Item as Item | undefined;
+ return liveCreatedId(item ? { createdId: String(item.createdId), ttl: Number(item.ttl) } : null, now);
+ }
+}
diff --git a/factory/api/src/errors.ts b/factory/api/src/errors.ts
index 4401619c9..79e6dd340 100644
--- a/factory/api/src/errors.ts
+++ b/factory/api/src/errors.ts
@@ -30,6 +30,20 @@ export class ForbiddenError extends ApiError {
}
}
+// 委任の資格情報で、人だけが確定できる操作を呼んだ(#2384 の I4)
+export class HumanOnlyError extends ApiError {
+ constructor() {
+ super(403, "human_only", "この操作は人が画面で確定します。委任の資格情報からは行えません");
+ }
+}
+
+// 範囲が読み取りだけの委任の資格情報で、書き込みの操作を呼んだ(#2384 の I5)
+export class ScopeError extends ApiError {
+ constructor() {
+ super(403, "scope_insufficient", "読み取りだけの委任では、この操作を行えません");
+ }
+}
+
export class InvalidRequestError extends ApiError {
constructor(message = "要求の形が正しくありません", code = "invalid_request", details?: Record) {
super(400, code, message, details);
diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts
index 89fc9efe5..d4c73c04c 100644
--- a/factory/api/src/handlers/api.ts
+++ b/factory/api/src/handlers/api.ts
@@ -9,6 +9,9 @@ import { requireCaller, type AuthDeps, type AuthEnv } from "../auth/resolve.ts";
import { registerMe } from "../routes/me.ts";
import { registerTeams } from "../routes/teams.ts";
import { registerAccounts } from "../routes/accounts.ts";
+import { registerDelegations } from "../routes/delegations.ts";
+import { DelegationService } from "../delegations/service.ts";
+import { DynamoDelegationStore } from "../delegations/store.ts";
import { registerFactory, type FactoryDeps } from "../routes/factory.ts";
import { factoryDepsFromEnv } from "../runs/deps.ts";
@@ -69,6 +72,7 @@ export function createApiApp(
registerMe(app);
registerTeams(app);
registerAccounts(app);
+ registerDelegations(app);
if (factory) registerFactory(app, factory);
return app;
}
@@ -89,6 +93,8 @@ export function depsFromEnv(): AuthDeps {
clientId: requireEnv("COGNITO_CLIENT_ID"),
}),
accounts: new AccountService({ store: new DynamoAccountStore(requireEnv("ACCOUNTS_TABLE_NAME")) }),
+ // 委任の資格情報の表(インフラが DELEGATIONS_TABLE_NAME で渡す)。無い間は委任の資格情報を 401 にし、委任の操作を 503 にする
+ delegations: process.env.DELEGATIONS_TABLE_NAME ? new DelegationService({ store: new DynamoDelegationStore(process.env.DELEGATIONS_TABLE_NAME) }) : null,
};
return cached;
}
diff --git a/factory/api/src/routes/delegations.ts b/factory/api/src/routes/delegations.ts
new file mode 100644
index 000000000..c6bf1c2a4
--- /dev/null
+++ b/factory/api/src/routes/delegations.ts
@@ -0,0 +1,19 @@
+// エージェントへの委任の操作(openapi/delegations.yaml。#2384 の F2)。委任は利用者に属し、アカウントに属さない。
+// 発行と取り消しは人だけが確定でき(扱い human)、委任の資格情報からは操作の登録の層が断る
+import type { Context, Hono } from "hono";
+import type { AuthEnv } from "../auth/resolve.ts";
+import type { DelegationService } from "../delegations/service.ts";
+import { ServiceUnavailableError } from "../errors.ts";
+import { registerList, registerOperation } from "./operation.ts";
+
+function service(c: Context): DelegationService {
+ const d = c.get("auth").delegations;
+ if (!d) throw new ServiceUnavailableError("delegation_unavailable");
+ return d;
+}
+
+export function registerDelegations(app: Hono): void {
+ registerList(app, "listDelegations", (c) => service(c).list(c.get("caller")));
+ registerOperation(app, "createDelegation", { run: (e, _ctx, _s, c) => service(c).issue(c.get("caller"), e) });
+ registerOperation(app, "revokeDelegation", { run: (e, _ctx, _s, c) => service(c).revoke(c.get("caller"), e.credential_id) });
+}
diff --git a/factory/api/src/routes/operation.ts b/factory/api/src/routes/operation.ts
index bad0a7713..989f28f90 100644
--- a/factory/api/src/routes/operation.ts
+++ b/factory/api/src/routes/operation.ts
@@ -4,7 +4,7 @@ import type { Context, Hono } from "hono";
import { MAX_ITEMS, operation, type Operation, type OperationId } from "@ndf-factory/contracts/operations";
import type { AccountContext } from "../accounts/model.ts";
import { resolveFromRequest, type AuthEnv, type OperationTrace } from "../auth/resolve.ts";
-import { InvalidRequestError } from "../errors.ts";
+import { HumanOnlyError, InvalidRequestError, ScopeError } from "../errors.ts";
import { runElements, type ElementResult } from "./elements.ts";
export type Element = Record;
@@ -25,9 +25,18 @@ async function contextOf(c: Context, op: Operation): Promise, op: Operation): void {
+ const delegation = c.get("caller").delegation;
+ if (!delegation) return;
+ if (op.access === "human") throw new HumanOnlyError();
+ if (op.access === "write" && delegation.scope !== "read_write") throw new ScopeError();
+}
+
function trace(c: Context, op: Operation): OperationTrace {
const t: OperationTrace = { operationId: op.operationId, accountId: op.path.includes("{accountId}") ? (c.req.param("accountId") ?? null) : null, targetIds: [], reasons: [] };
c.set("operation", t);
+ guard(c, op);
return t;
}
diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts
index 28f2afeef..d093aa207 100644
--- a/factory/api/src/testing/app.ts
+++ b/factory/api/src/testing/app.ts
@@ -1,8 +1,33 @@
import { operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations";
import { AccountService } from "../accounts/service.ts";
import { MemoryAccountStore } from "../accounts/memory-store.ts";
+import { MemoryDelegationStore } from "../delegations/memory-store.ts";
+import { DelegationService } from "../delegations/service.ts";
import { createApiApp, type LogEntry } from "../handlers/api.ts";
import { createTokenKit, type TokenKit } from "./tokens.ts";
+import type { FactoryDeps } from "../routes/factory.ts";
+import { CredentialService, type SecretsPort } from "../credentials/service.ts";
+import { RepositoryService } from "../repositories/service.ts";
+import { memoryProgressReader } from "../progress/read.ts";
+import { LoopControl } from "../runs/loops.ts";
+import { createRunsHarness, type RunsHarness } from "./runs.ts";
+
+// 秘密の置き場の偽物(値はメモリだけに置く)
+export class FakeSecrets implements SecretsPort {
+ readonly values = new Map();
+ private n = 0;
+ async create(name: string, value: string) {
+ const arn = `arn:aws:secretsmanager:us-east-1:111111111111:secret:${name}-${++this.n}`;
+ this.values.set(arn, value);
+ return arn;
+ }
+ async put(arn: string, value: string) {
+ this.values.set(arn, value);
+ }
+ async delete(arn: string) {
+ this.values.delete(arn);
+ }
+}
// テストで本文を読みやすくするため、json() の型を any にした応答
export type TestResponse = Omit & { json(): Promise };
@@ -35,23 +60,60 @@ export interface TestApi {
app: ReturnType;
store: MemoryAccountStore;
service: AccountService;
+ delegationStore: MemoryDelegationStore;
+ delegations: DelegationService;
+ // 時刻を進められる時計(委任の期限の確かめ)
+ clock: { now: Date };
+ // factory を求めたときだけ。実行の表・GitHub・秘密の置き場の偽物
+ h: RunsHarness | null;
+ secrets: FakeSecrets;
tokens: TokenKit;
logs: LogEntry[];
call(method: string, path: string, opts?: { user?: string; idp?: "Google" | "GitHub"; body?: unknown; authorization?: string }): Promise;
op(operationId: OperationId, opts?: OpOptions): Promise;
}
-export async function createTestApi(): Promise {
+export async function createTestApi(opts: { factory?: boolean } = {}): Promise {
const store = new MemoryAccountStore();
let n = 0;
const service = new AccountService({ store, newId: () => `00000000-0000-4000-8000-${String(++n).padStart(12, "0")}` });
const tokens = await createTokenKit();
const logs: LogEntry[] = [];
- const app = createApiApp(() => ({ verifier: tokens.verifier, accounts: service }), (e) => logs.push(e));
+ const clock = { now: new Date("2026-11-04T00:00:00.000Z") };
+ const delegationStore = new MemoryDelegationStore();
+ const delegations = new DelegationService({ store: delegationStore, now: () => clock.now });
+ const secrets = new FakeSecrets();
+ const h = opts.factory ? await createRunsHarness() : null;
+ let factory: FactoryDeps | null = null;
+ if (h) {
+ const key = new TextEncoder().encode("proof-key-for-tests-0123456789ab");
+ factory = {
+ repositories: new RepositoryService({ table: h.table, github: h.github, proofKey: async () => key }),
+ credentials: new CredentialService(h.table, secrets),
+ control: h.control,
+ loops: new LoopControl({ table: h.table, control: h.control, reader: memoryProgressReader(h.progress), now: () => h.clock.now }),
+ organizer: h.organizer,
+ table: h.table,
+ github: h.github,
+ candidateModel: async (_s, issues) => issues.map((i) => ({ number: i.number, selected: true, reason: "理由" })),
+ reader: memoryProgressReader(h.progress),
+ };
+ }
+ const app = createApiApp(
+ () => ({ verifier: tokens.verifier, accounts: service, delegations }),
+ (e) => logs.push(e),
+ () => null,
+ factory && (() => factory!),
+ );
const api: TestApi = {
app,
store,
service,
+ delegationStore,
+ delegations,
+ clock,
+ h,
+ secrets,
tokens,
logs,
async call(method, path, opts = {}) {
diff --git a/factory/web/src/App.tsx b/factory/web/src/App.tsx
index 71b92d522..0249b33d6 100644
--- a/factory/web/src/App.tsx
+++ b/factory/web/src/App.tsx
@@ -20,6 +20,7 @@ import { SprintDetailPage } from "./pages/SprintDetailPage.tsx";
import { BacklogPage } from "./pages/BacklogPage.tsx";
import { LoopsPage } from "./pages/LoopsPage.tsx";
import { LoopDetailPage } from "./pages/LoopDetailPage.tsx";
+import { DelegationsPage } from "./pages/DelegationsPage.tsx";
// 地図の available の行の画面。経路は screens.ts だけが持つ
const ELEMENTS: Partial> = {
@@ -36,6 +37,7 @@ const ELEMENTS: Partial> = {
backlog: ,
loops: ,
loopDetail: ,
+ delegations: ,
};
export interface AppProps {
diff --git a/factory/web/src/api.test.ts b/factory/web/src/api.test.ts
index 5552287d9..b3aa988b1 100644
--- a/factory/web/src/api.test.ts
+++ b/factory/web/src/api.test.ts
@@ -62,6 +62,9 @@ const CALLS: { [K in keyof Api]: (api: Api) => Promise } = {
cancelLoop: (a) => a.cancelLoop("p-1", "l1"),
requestNextLap: (a) => a.requestNextLap("p-1", "l1"),
answerLoop: (a) => a.answerLoop("p-1", "l1", 3, "approved"),
+ delegations: (a) => a.delegations(),
+ createDelegation: (a) => a.createDelegation({ client_name: "c", scope: "read", expires_in_seconds: 900 }),
+ revokeDelegation: (a) => a.revokeDelegation("d1"),
};
describe("画面の API クライアントと契約(受け入れ条件 1)", () => {
diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts
index a5a9c7927..873d61929 100644
--- a/factory/web/src/api.ts
+++ b/factory/web/src/api.ts
@@ -231,6 +231,25 @@ export interface LoopDetail {
pending: LoopPending[];
}
+// エージェントへの委任(factory/contracts/openapi/delegations.yaml。#2384)
+export type DelegationScope = "read" | "read_write";
+
+export interface Delegation {
+ credential_id: string;
+ client_name: string;
+ scope: DelegationScope;
+ created_at: string;
+ expires_at: string;
+ revoked_at: string | null;
+ state: "active" | "expired" | "revoked";
+}
+
+export interface DelegationInput {
+ client_name: string;
+ scope: DelegationScope;
+ expires_in_seconds: number;
+}
+
export interface Api {
me(): Promise;
account(id: string): Promise;
@@ -269,6 +288,11 @@ export interface Api {
cancelLoop(id: string, loopId: string): Promise<{ state: LoopState }>;
requestNextLap(id: string, loopId: string): Promise<{ state: LoopState }>;
answerLoop(id: string, loopId: string, issue: number, decision: LoopDecision, pr?: number): Promise<{ pending: LoopPending[] }>;
+ // 自分が発行した委任の資格情報(新しい順)
+ delegations(): Promise;
+ // 値(credential)は発行の結果で 1 度だけ返る
+ createDelegation(input: DelegationInput): Promise<{ delegation: Delegation; credential: string | null }>;
+ revokeDelegation(credentialId: string): Promise;
}
// 要素の結果(#2384)。入力と同じ順・同じ数で返る
@@ -372,5 +396,8 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (.
cancelLoop: (id, loop_id) => one("cancelLoop", id, { loop_id }),
requestNextLap: (id, loop_id) => one("startNextLap", id, { loop_id }),
answerLoop: (id, loop_id, issue, decision, pr) => one("answerLoopPending", id, { loop_id, issue, decision, ...(pr === undefined ? {} : { pr }) }),
+ delegations: () => list("listDelegations", null),
+ createDelegation: (input) => one("createDelegation", null, { ...input, idempotency_key: newKey() }),
+ revokeDelegation: async (credentialId) => (await values("revokeDelegation", null, "credential_id", [credentialId]))[0]!,
};
}
diff --git a/factory/web/src/context.tsx b/factory/web/src/context.tsx
index fbe53641a..f96393dc3 100644
--- a/factory/web/src/context.tsx
+++ b/factory/web/src/context.tsx
@@ -12,6 +12,8 @@ export interface Services {
connection: ConnectionSource;
// 一覧と詳細を読み直す間隔(ミリ秒)。無ければ既定の 30 秒
pollIntervalMs?: number;
+ // API の入口。委任の画面が MCP のクライアントへの登録の例に出す
+ apiUrl?: string;
}
export const ServicesContext = createContext(null);
diff --git a/factory/web/src/dummy/api.ts b/factory/web/src/dummy/api.ts
index 1e00b781e..09a56f631 100644
--- a/factory/web/src/dummy/api.ts
+++ b/factory/web/src/dummy/api.ts
@@ -1,4 +1,4 @@
-import { ApiFailure, type Api } from "../api.ts";
+import { ApiFailure, type Api, type Delegation } from "../api.ts";
import * as data from "./data.ts";
// 場面: normal は決まったデータ、empty は一覧が 0 件、error は読み込みがすべて失敗する
@@ -15,6 +15,8 @@ export function createDummyApi(scenario: Scenario = "normal"): Api {
if (!found) throw new ApiFailure(403, "forbidden", "forbidden");
return found;
};
+ // 発行した委任はこの画面の間だけ持つ(値はダミーで、本物の API へは届かない)
+ const delegations: Delegation[] = scenario === "empty" ? [] : structuredClone(data.delegations);
const loopOf = (loopId: string) => {
const found = data.loopDetails.find((d) => d.loop.loop_id === loopId);
if (!found) throw new ApiFailure(404, "loop_not_found", "loop_not_found");
@@ -78,5 +80,26 @@ export function createDummyApi(scenario: Scenario = "normal"): Api {
requestNextLap: () => read(() => ({ state: "running" as const })),
answerLoop: (_id, loopId, issue, decision, pr) =>
read(() => ({ pending: loopOf(loopId).pending.map((p) => (p.number === issue ? { ...p, answer: { decision, pr: pr ?? null } } : p)) })),
+ delegations: () => read(() => delegations),
+ createDelegation: async (input) => {
+ const now = new Date();
+ const delegation: Delegation = {
+ credential_id: `dummy${String(delegations.length + 1).padStart(21, "0")}`,
+ client_name: input.client_name,
+ scope: input.scope,
+ created_at: now.toISOString(),
+ expires_at: new Date(now.getTime() + input.expires_in_seconds * 1000).toISOString(),
+ revoked_at: null,
+ state: "active",
+ };
+ delegations.unshift(delegation);
+ return read(() => ({ delegation, credential: `ndfd_${delegation.credential_id}_${data.DUMMY_MARKER}` }));
+ },
+ revokeDelegation: async (credentialId) => {
+ const found = delegations.find((d) => d.credential_id === credentialId);
+ if (!found) throw new ApiFailure(403, "forbidden", "forbidden");
+ if (found.state === "active") Object.assign(found, { state: "revoked", revoked_at: new Date().toISOString() });
+ return read(() => found);
+ },
};
}
diff --git a/factory/web/src/dummy/data.ts b/factory/web/src/dummy/data.ts
index 2c42cf860..0202d780a 100644
--- a/factory/web/src/dummy/data.ts
+++ b/factory/web/src/dummy/data.ts
@@ -1,4 +1,4 @@
-import type { AccountDetail, Backlog, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts";
+import type { AccountDetail, Backlog, Delegation, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts";
// ダミーデータ。本番の成果物にこの印が出たら check:bundle が落とす。実在の人・秘密を書かない
export const DUMMY_MARKER = "ndf-factory-dummy-data";
@@ -281,3 +281,16 @@ export const loopDetails: LoopDetail[] = [
pending: [],
},
];
+
+// 委任の資格情報の一覧(値は持たない)
+export const delegations: Delegation[] = [
+ {
+ credential_id: "dummy000000000000000000001",
+ client_name: "見本の Claude Code",
+ scope: "read",
+ created_at: "2026-11-04T00:00:00.000Z",
+ expires_at: "2026-11-04T01:00:00.000Z",
+ revoked_at: null,
+ state: "expired",
+ },
+];
diff --git a/factory/web/src/i18n/ja.json b/factory/web/src/i18n/ja.json
index b07cc7b59..566cfd820 100644
--- a/factory/web/src/i18n/ja.json
+++ b/factory/web/src/i18n/ja.json
@@ -29,7 +29,8 @@
"billing": "請求と使用量",
"runners": "手元のマシン",
"loops": "ループ",
- "loopDetail": "ループの詳細"
+ "loopDetail": "ループの詳細",
+ "delegations": "エージェントへの委任"
},
"connection": {
"label": "つながりの状態",
@@ -390,5 +391,32 @@
"exitCode": "終了コード",
"back": "リポジトリへ戻る",
"issueLine": "#{{number}} {{title}}"
+ },
+ "delegations": {
+ "intro": "手元の Claude Code などのエージェントに、あなたの代わりに Factory を操作させる資格情報を発行します。承認と、キー・席・チーム・委任そのものの操作は、委任からは行えません。",
+ "clientName": "クライアントの名前",
+ "needClientName": "名前を入力してください",
+ "scope": "範囲",
+ "scopeRead": "読み取りだけ",
+ "scopeReadWrite": "読み書き",
+ "expires": "期限",
+ "minutes": "{{count}} 分",
+ "hours": "{{count}} 時間",
+ "issue": "発行する",
+ "issuedTitle": "発行した資格情報",
+ "issuedNote": "この値は今だけ表示します。閉じると二度と表示しません。",
+ "value": "資格情報の値",
+ "registerExample": "MCP のクライアントへの登録の例",
+ "close": "閉じる",
+ "list": "発行した資格情報",
+ "empty": "まだ委任していません",
+ "createdAt": "発行",
+ "expiresAt": "期限",
+ "state": "状態",
+ "stateActive": "有効",
+ "stateExpired": "期限切れ",
+ "stateRevoked": "取り消し済み",
+ "revoke": "取り消す",
+ "offline": "オフラインの間は発行と取り消しができません"
}
}
diff --git a/factory/web/src/main.tsx b/factory/web/src/main.tsx
index 16f3b25c3..37573693f 100644
--- a/factory/web/src/main.tsx
+++ b/factory/web/src/main.tsx
@@ -20,7 +20,7 @@ async function buildServices(): Promise {
}
const config = embeddedConfig();
const auth = createAuth(config);
- return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs };
+ return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs, apiUrl: config.apiUrl };
}
const style = document.createElement("style");
diff --git a/factory/web/src/pages/AccountPage.tsx b/factory/web/src/pages/AccountPage.tsx
index ec8930929..79fe28586 100644
--- a/factory/web/src/pages/AccountPage.tsx
+++ b/factory/web/src/pages/AccountPage.tsx
@@ -57,6 +57,9 @@ export function AccountPage() {
{t("account.idp", { idp: me.idp === "google" ? t("account.idpGoogle") : t("account.idpGithub") })}
+
+ {t("screens.delegations")}
+
{t("account.plan", { plan: t(`plan.${personal.plan}`) })}
diff --git a/factory/web/src/pages/DelegationsPage.tsx b/factory/web/src/pages/DelegationsPage.tsx
new file mode 100644
index 000000000..7d22925cc
--- /dev/null
+++ b/factory/web/src/pages/DelegationsPage.tsx
@@ -0,0 +1,161 @@
+import { useCallback, useEffect, useState } from "react";
+import { Navigate } from "react-router";
+import { useTranslation } from "react-i18next";
+import type { Delegation, DelegationScope } from "../api.ts";
+import { useConnection, useFailureHandler, useServices } from "../context.tsx";
+import { useFormat } from "../i18n/index.ts";
+import { Alert } from "../ui/Alert.tsx";
+import { Button } from "../ui/Button.tsx";
+import { DataList } from "../ui/DataList.tsx";
+import { EmptyState } from "../ui/EmptyState.tsx";
+import { ErrorState } from "../ui/ErrorState.tsx";
+import { LoadingState } from "../ui/LoadingState.tsx";
+import { PageHeader } from "../ui/PageHeader.tsx";
+import { Section } from "../ui/Section.tsx";
+import { SelectField } from "../ui/SelectField.tsx";
+import { StatusBadge, type StatusTone } from "../ui/StatusBadge.tsx";
+import { TextField } from "../ui/TextField.tsx";
+
+const MAX_NAME = 64;
+// 期限の選択肢(秒)。API は 300〜3,600 秒を受ける
+const EXPIRES = [900, 1800, 3600] as const;
+const TONE: Record = { active: "success", expired: "neutral", revoked: "warning" };
+const STATE_KEY = { active: "delegations.stateActive", expired: "delegations.stateExpired", revoked: "delegations.stateRevoked" } as const;
+
+// エージェントへの委任の画面(#2384)。値は発行の直後に 1 度だけ出し、閉じると二度と出さない。
+// 発行と取り消しは人だけが確定でき、オフラインの間は確定させない
+export function DelegationsPage() {
+ const { t } = useTranslation();
+ const format = useFormat();
+ const { auth, api, apiUrl } = useServices();
+ const { online } = useConnection();
+ const onFailure = useFailureHandler();
+ const [rows, setRows] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [name, setName] = useState("");
+ const [scope, setScope] = useState("read");
+ const [expires, setExpires] = useState<(typeof EXPIRES)[number]>(3600);
+ const [issued, setIssued] = useState(null);
+ const [message, setMessage] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const hasToken = auth.getToken() !== null;
+
+ const load = useCallback(async () => {
+ setLoadError(null);
+ try {
+ setRows(await api.delegations());
+ } catch (err) {
+ setLoadError(onFailure(err));
+ }
+ }, [api, onFailure]);
+
+ useEffect(() => {
+ if (hasToken) void load();
+ }, [hasToken, load]);
+
+ if (!hasToken) return ;
+
+ const issue = async () => {
+ setMessage(null);
+ if (name.trim() === "") return setMessage(t("delegations.needClientName"));
+ setBusy(true);
+ try {
+ const { delegation, credential } = await api.createDelegation({ client_name: name, scope, expires_in_seconds: expires });
+ setIssued(credential);
+ setName("");
+ setRows((prev) => [delegation, ...(prev ?? []).filter((d) => d.credential_id !== delegation.credential_id)]);
+ } catch (err) {
+ setMessage(onFailure(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const revoke = async (credentialId: string) => {
+ setMessage(null);
+ setBusy(true);
+ try {
+ const next = await api.revokeDelegation(credentialId);
+ setRows((prev) => (prev ?? []).map((d) => (d.credential_id === credentialId ? next : d)));
+ } catch (err) {
+ setMessage(onFailure(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const example = issued && JSON.stringify({ url: `${apiUrl ?? ""}/mcp`, headers: { Authorization: `Bearer ${issued}` } }, null, 2);
+
+ return (
+ <>
+
+ {t("delegations.intro")}
+ {issued && (
+
+ {t("delegations.issuedNote")}
+
+ {t("delegations.value")} {issued}{" "}
+
+
+
+ {example}
+
+
+
+ )}
+
+
setName(e.target.value)} />
+ setScope(e.target.value as DelegationScope)}>
+
+
+
+ setExpires(Number(e.target.value) as (typeof EXPIRES)[number])}>
+ {EXPIRES.map((s) => (
+
+ ))}
+
+
+
+
+
+ {!online && {t("delegations.offline")}
}
+ {message && {message}}
+
+ {loadError ? (
+ void load()} />
+ ) : !rows ? (
+
+ ) : rows.length === 0 ? (
+
+ ) : (
+
+ >
+ );
+}
diff --git a/factory/web/src/pages/delegations.test.tsx b/factory/web/src/pages/delegations.test.tsx
new file mode 100644
index 000000000..52b4b043c
--- /dev/null
+++ b/factory/web/src/pages/delegations.test.tsx
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { cleanup, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router";
+import { App } from "../App.tsx";
+import { createDummyServices } from "../dummy/index.ts";
+import type { LocaleSettings } from "../i18n/settings.ts";
+
+afterEach(cleanup);
+
+const tokyo: LocaleSettings = { language: "ja", locale: "ja-JP", timeZone: "Asia/Tokyo" };
+
+function show(search = "") {
+ render(
+
+
+ ,
+ );
+}
+
+describe("エージェントへの委任(#2384)", () => {
+ it("発行すると値を 1 度だけ出し、閉じると二度と出さない。一覧の先頭に有効な行が足され、取り消せる", async () => {
+ show();
+ await userEvent.type(await screen.findByLabelText("クライアントの名前"), "手元の Claude Code");
+ await userEvent.click(screen.getByRole("button", { name: "発行する" }));
+ const value = (await screen.findByText(/^ndfd_/)).textContent!;
+ expect(screen.getByLabelText("MCP のクライアントへの登録の例").textContent).toContain(`Bearer ${value}`);
+ await userEvent.click(screen.getByRole("button", { name: "閉じる" }));
+ expect(document.body.textContent).not.toContain(value);
+ const rows = within(screen.getByRole("table", { name: "発行した資格情報" })).getAllByRole("row").slice(1);
+ expect(within(rows[0]!).getByText("有効")).toBeTruthy();
+ expect(within(rows[1]!).getByText("期限切れ")).toBeTruthy();
+ expect(within(rows[1]!).queryByRole("button", { name: "取り消す" })).toBeNull();
+ await userEvent.click(within(rows[0]!).getByRole("button", { name: "取り消す" }));
+ expect(await within(rows[0]!).findByText("取り消し済み")).toBeTruthy();
+ });
+
+ it("名前が無ければ発行しない", async () => {
+ show();
+ await userEvent.click(await screen.findByRole("button", { name: "発行する" }));
+ expect(screen.getByRole("alert").textContent).toBe("名前を入力してください");
+ });
+
+ it("オフラインの間は発行のボタンを押せない", async () => {
+ show("?connection=offline");
+ expect(((await screen.findByRole("button", { name: "発行する" })) as HTMLButtonElement).disabled).toBe(true);
+ expect(screen.getByText("オフラインの間は発行と取り消しができません")).toBeTruthy();
+ });
+
+ it("まだ委任していなければ空の表示", async () => {
+ show("?scenario=empty");
+ expect(await screen.findByText("まだ委任していません")).toBeTruthy();
+ });
+});
diff --git a/factory/web/src/screens.ts b/factory/web/src/screens.ts
index 6bf1dec0c..b0660841f 100644
--- a/factory/web/src/screens.ts
+++ b/factory/web/src/screens.ts
@@ -21,7 +21,8 @@ export type ScreenId =
| "billing"
| "runners"
| "loops"
- | "loopDetail";
+ | "loopDetail"
+ | "delegations";
export interface Screen {
id: ScreenId;
@@ -52,6 +53,8 @@ export const SCREENS: readonly Screen[] = [
{ id: "runners", path: null, roles: OWNERS, nav: false, status: "planned" },
{ id: "loops", path: "/accounts/:accountId/repositories/:repositoryId/loops", roles: ALL_ROLES, nav: false, status: "available" },
{ id: "loopDetail", path: "/accounts/:accountId/loops/:loopId", roles: ALL_ROLES, nav: false, status: "available" },
+ // 委任は利用者に属し、アカウントに属さない。自分のアカウントからたどる(#2384)
+ { id: "delegations", path: "/delegations", roles: ALL_ROLES, nav: false, status: "available" },
];
export type AvailableScreen = Screen & { path: string };
From 088db4551075b1cb9cc8b3d97a8d5e45971854ef Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 22:40:32 +0000
Subject: [PATCH 04/16] =?UTF-8?q?=E5=A7=94=E4=BB=BB=E3=81=AE=E6=A4=9C?=
=?UTF-8?q?=E6=9F=BB=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E5=9E=8B?=
=?UTF-8?q?=E3=81=AE=E8=AA=A4=E3=82=8A=E3=82=92=E7=9B=B4=E3=81=99=EF=BC=88?=
=?UTF-8?q?#2384=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
factory/api/src/delegations/delegations.test.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/factory/api/src/delegations/delegations.test.ts b/factory/api/src/delegations/delegations.test.ts
index 6972288f8..99473bf14 100644
--- a/factory/api/src/delegations/delegations.test.ts
+++ b/factory/api/src/delegations/delegations.test.ts
@@ -152,10 +152,11 @@ describe("範囲と人だけが確定できる操作(受け入れ条件 13・1
const human = OPERATIONS.filter((op) => op.access === "human");
expect(human).toHaveLength(10);
for (const op of human) {
- const query = Object.fromEntries(op.keys.length && (op.kind === "select" || op.kind === "delete") ? [[op.keys[0]!, ["x"]]] : []);
+ const byQuery = ["select", "delete"].includes(op.kind);
+ const query = Object.fromEntries(op.keys.length && byQuery ? [[op.keys[0]!, ["x"]]] : []);
if (op.operationId === "revokeDelegation") query.credential_id = [delegation.credential_id];
const item = { user_id: "u-2", seat_type: "premium", name: "t", kind: "anthropic_api_key", api_key: "sk-SECRET", loop_id: "l", issue: 1, decision: "approved", repository_id: 1, seq: 1, choice: "reject", client_name: "c", scope: "read", expires_in_seconds: 600, idempotency_key: "h" };
- const hasBody = !["select", "delete"].includes(op.kind);
+ const hasBody = !byQuery;
const res = await api.call(op.method, operationUrl(op, own, query), { ...as(credential), body: hasBody ? [item] : undefined });
expect(res.status, op.operationId).toBe(403);
expect((await res.json()).error.code, op.operationId).toBe("human_only");
From c3dd76d3fdec3ebffef78f6bcdf618ea8339f125 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 22:43:11 +0000
Subject: [PATCH 05/16] =?UTF-8?q?=E5=88=A9=E7=94=A8=E8=80=85=E5=90=91?=
=?UTF-8?q?=E3=81=91=E3=81=AE=E6=93=8D=E4=BD=9C=E3=82=92=201=20=E5=9B=9E?=
=?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AB=E6=93=8D=E4=BD=9C=E3=81=AE=E8=A8=98?=
=?UTF-8?q?=E9=8C=B2=E3=81=B8=E8=BF=BD=E8=A8=98=E3=81=99=E3=82=8B=EF=BC=88?=
=?UTF-8?q?#2384=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
認証より外の中間処理が、成功・一部の失敗・400・401・403・404 のどれでも記録を 1 件追記する。
記録は許可した鍵の値と固定の列だけで組み、要求の本文を写さない。呼び出し元の種類は資格情報の種類と
MCP の内部の印だけで決める。追記に失敗したら記録の全体を構造化ログへ出し、503 audit_unavailable を返す。
保存の実装は追記の関数だけを持ち、表が無い間は構造化ログへ出す。ログの行に要求の識別子を足した。
Co-Authored-By: Claude Opus 5.5
---
factory/api/src/audit/audit.test.ts | 131 ++++++++++++++++++++++++
factory/api/src/audit/dynamo-log.ts | 19 ++++
factory/api/src/audit/log.ts | 76 ++++++++++++++
factory/api/src/audit/memory-log.ts | 14 +++
factory/api/src/audit/middleware.ts | 87 ++++++++++++++++
factory/api/src/audit/structured-log.ts | 11 ++
factory/api/src/auth/resolve.ts | 5 +-
factory/api/src/auth/verify.test.ts | 2 +-
factory/api/src/handlers/api.ts | 11 ++
factory/api/src/testing/app.ts | 6 +-
10 files changed, 359 insertions(+), 3 deletions(-)
create mode 100644 factory/api/src/audit/audit.test.ts
create mode 100644 factory/api/src/audit/dynamo-log.ts
create mode 100644 factory/api/src/audit/log.ts
create mode 100644 factory/api/src/audit/memory-log.ts
create mode 100644 factory/api/src/audit/middleware.ts
create mode 100644 factory/api/src/audit/structured-log.ts
diff --git a/factory/api/src/audit/audit.test.ts b/factory/api/src/audit/audit.test.ts
new file mode 100644
index 000000000..cc6a50caa
--- /dev/null
+++ b/factory/api/src/audit/audit.test.ts
@@ -0,0 +1,131 @@
+// 操作の記録(#2384 の受け入れ条件 19〜23、不変条件 I7〜I9・I16)
+import { beforeEach, describe, expect, it } from "vitest";
+import { createTestApi, firstResult, type TestApi } from "../testing/app.ts";
+import { DynamoAuditLog } from "./dynamo-log.ts";
+import { AUDIT_COLUMNS } from "./log.ts";
+import { MemoryAuditLog } from "./memory-log.ts";
+import { StructuredLogAudit } from "./structured-log.ts";
+
+let api: TestApi;
+beforeEach(async () => {
+ api = await createTestApi({ factory: true });
+});
+
+async function personal(user: string): Promise {
+ return (await (await api.op("getMe", { user })).json())[0].personal_account_id;
+}
+
+const last = () => api.audit.items.at(-1)!;
+
+describe("1 回の操作に 1 件(受け入れ条件 19・20、I7)", () => {
+ it("成功の記録は 7 項目と結果を持ち、要求の識別子でログの行とたどれる", async () => {
+ const acc = await personal("u-1");
+ const before = api.audit.items.length;
+ await api.op("listSprintRuns", { user: "u-1", accountId: acc });
+ expect(api.audit.items.length).toBe(before + 1);
+ expect(last()).toEqual({
+ record_id: expect.any(String),
+ at: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/),
+ user_id: "u-1",
+ credential_id: null,
+ via: "screen",
+ operation_id: "listSprintRuns",
+ account_id: acc,
+ target_ids: [],
+ outcome: "ok",
+ status: 200,
+ reason: null,
+ });
+ expect(api.logs.at(-1)!.request_id).toBe(last().record_id);
+ });
+
+ it("一部の要素が失敗すると partial と、失敗の code を重ねずに持つ。対象の ID は要素の鍵", async () => {
+ const acc = await personal("u-1");
+ await api.call("GET", `/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-a&sprint_run_id=sr-b`, { user: "u-1" });
+ expect(last()).toMatchObject({ outcome: "partial", reason: "forbidden", status: 200, target_ids: ["sprint_run_id=sr-a", "sprint_run_id=sr-b"] });
+ });
+
+ it("読み取りも、401・403・400 で断った要求も 1 件ずつ残る", async () => {
+ const acc = await personal("u-1");
+ await personal("u-2");
+ const before = api.audit.items.length;
+ await api.op("getMe", { authorization: "Bearer broken" });
+ expect(last()).toMatchObject({ outcome: "rejected", status: 401, reason: "unauthorized", user_id: null, operation_id: "getMe", via: "screen" });
+ await api.op("listSprintRuns", { user: "u-2", accountId: acc });
+ expect(last()).toMatchObject({ outcome: "rejected", status: 403, reason: "forbidden", user_id: "u-2", operation_id: "listSprintRuns", account_id: acc });
+ await api.call("GET", `/accounts/${acc}/sprint-runs/detail`, { user: "u-1" });
+ expect(last()).toMatchObject({ outcome: "rejected", status: 400, reason: "empty_items" });
+ await api.call("GET", "/nowhere", { user: "u-1" });
+ expect(last()).toMatchObject({ status: 404, operation_id: null });
+ expect(api.audit.items.length).toBe(before + 4);
+ });
+
+ it("委任の資格情報での要求は資格情報の ID と api を持ち、断った人だけの操作も残る", async () => {
+ const acc = await personal("u-1");
+ const issued = await firstResult(
+ await api.op("createDelegation", { user: "u-1", items: [{ client_name: "見本のクライアント", scope: "read", expires_in_seconds: 600, idempotency_key: "a" }] }),
+ );
+ expect(last()).toMatchObject({ operation_id: "createDelegation", target_ids: ["idempotency_key=a", `credential_id=${issued.body.delegation.credential_id}`] });
+ const auth = `Bearer ${issued.body.credential}`;
+ await api.op("listSprintRuns", { authorization: auth, accountId: acc });
+ expect(last()).toMatchObject({ via: "api", credential_id: issued.body.delegation.credential_id, user_id: "u-1", outcome: "ok" });
+ await api.op("putModelCredential", { authorization: auth, accountId: acc, items: [{ kind: "anthropic_api_key", api_key: "k" }] });
+ expect(last()).toMatchObject({ outcome: "rejected", status: 403, reason: "human_only" });
+ });
+
+ it("外から付けた呼び出し元のヘッダーでは種類が変わらない(I16)", async () => {
+ await personal("u-1");
+ const res = await api.app.request("/me", { headers: { Authorization: await api.tokens.bearer("u-1"), "x-ndf-via": "mcp" } });
+ expect(res.status).toBe(200);
+ expect(last().via).toBe("screen");
+ });
+
+ it("追記に失敗したら 503 audit_unavailable を返す", async () => {
+ await personal("u-1");
+ api.audit.fail = true;
+ const res = await api.op("getMe", { user: "u-1" });
+ expect(res.status).toBe(503);
+ expect((await res.json()).error.code).toBe("audit_unavailable");
+ });
+});
+
+describe("書き換えと削除の経路が無い(受け入れ条件 21、I8)", () => {
+ it("保存の実装は追記の関数だけを持つ", () => {
+ for (const cls of [MemoryAuditLog, DynamoAuditLog, StructuredLogAudit]) {
+ expect(Object.getOwnPropertyNames(cls.prototype).sort(), cls.name).toEqual(["append", "constructor"]);
+ }
+ });
+});
+
+describe("秘密と個人情報を書かない(受け入れ条件 22・23、I9)", () => {
+ it("キー・ロールの ARN・ログインの token・委任の値・GitHub の code と proof・名前を含む要求でも、記録に値が入らない", async () => {
+ const acc = await personal("u-1");
+ const login = await api.tokens.bearer("u-1");
+ const secrets = [
+ "sk-ant-SECRET-0001",
+ "arn:aws:iam::123456789012:role/secret-role",
+ login.slice("Bearer ".length),
+ "gh-code-SECRET",
+ "gh-state-SECRET",
+ "proof-SECRET",
+ "秘密のチーム",
+ "秘密のクライアント",
+ ];
+ await api.op("putModelCredential", { user: "u-1", accountId: acc, items: [{ kind: "anthropic_api_key", api_key: secrets[0] }] });
+ await api.op("putModelCredential", { user: "u-1", accountId: acc, items: [{ kind: "aws_role", role_arn: secrets[1], region: "us-east-1" }] });
+ await api.op("postRepositoryCandidates", { user: "u-1", accountId: acc, items: [{ code: secrets[3], state: secrets[4] }] });
+ await api.op("registerRepository", { user: "u-1", accountId: acc, items: [{ proof: secrets[5], repository_id: 101 }] });
+ await api.op("createTeam", { user: "u-1", items: [{ name: secrets[6], seat_type: "standard", idempotency_key: "t" }] });
+ const issued = await firstResult(
+ await api.op("createDelegation", { user: "u-1", items: [{ client_name: secrets[7], scope: "read", expires_in_seconds: 600, idempotency_key: "c" }] }),
+ );
+ await api.op("getMe", { authorization: `Bearer ${issued.body.credential}` });
+ const text = JSON.stringify(api.audit.items);
+ for (const s of [...secrets, issued.body.credential]) expect(text, s).not.toContain(s);
+ });
+
+ it("記録の列は固定の一覧だけで、メールアドレス・氏名・課題の題と本文を写す列を持たない", async () => {
+ await personal("u-1");
+ for (const item of api.audit.items) expect(Object.keys(item).sort()).toEqual([...AUDIT_COLUMNS].sort());
+ });
+});
diff --git a/factory/api/src/audit/dynamo-log.ts b/factory/api/src/audit/dynamo-log.ts
new file mode 100644
index 000000000..00ccee2cd
--- /dev/null
+++ b/factory/api/src/audit/dynamo-log.ts
@@ -0,0 +1,19 @@
+// 操作の記録の表(#2384 の「データ構造」)。区分の鍵は record_id だけで、条件つきの追加だけを行う(I8)
+import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
+import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
+import { toItem, type AuditLog, type AuditRecord } from "./log.ts";
+
+export class DynamoAuditLog implements AuditLog {
+ private readonly doc: DynamoDBDocumentClient;
+
+ constructor(
+ private readonly tableName: string,
+ client: DynamoDBClient = new DynamoDBClient({}),
+ ) {
+ this.doc = DynamoDBDocumentClient.from(client, { marshallOptions: { removeUndefinedValues: true } });
+ }
+
+ async append(record: AuditRecord): Promise {
+ await this.doc.send(new PutCommand({ TableName: this.tableName, Item: toItem(record), ConditionExpression: "attribute_not_exists(record_id)" }));
+ }
+}
diff --git a/factory/api/src/audit/log.ts b/factory/api/src/audit/log.ts
new file mode 100644
index 000000000..aebf6efc5
--- /dev/null
+++ b/factory/api/src/audit/log.ts
@@ -0,0 +1,76 @@
+// 操作の記録(#2384 の集約「操作の記録」・不変条件 I7〜I9)。利用者向けの操作 1 回に 1 件を追記する。
+// 保存の契約は追記の関数 1 つだけを持ち、書き換えと削除の関数を持たない(I8)。
+// 記録は要求の本文を写さず、許可した鍵の値と固定の列だけで組む(決定 12・I9)
+import { AUDIT_KEYS } from "@ndf-factory/contracts/operations";
+import type { Via } from "../accounts/model.ts";
+
+export type AuditOutcome = "ok" | "partial" | "rejected";
+
+export interface AuditRecord {
+ // 要求の識別子(API Gateway の要求の ID、無ければ UUID)。構造化ログの同じ要求の行とたどれる
+ recordId: string;
+ // 受けた日時(UTC、ミリ秒まで)
+ at: string;
+ // 空は 401 で利用者を決められなかった
+ userId: string | null;
+ // 空は人の直の操作か、401 で決められなかった
+ credentialId: string | null;
+ via: Via;
+ // 空は契約に無い経路
+ operationId: string | null;
+ accountId: string | null;
+ // <鍵の名前>=<値>。許可した鍵だけ
+ targetIds: string[];
+ outcome: AuditOutcome;
+ status: number;
+ // rejected の誤りの code。partial は失敗した要素の code を重ねずに , で並べる。ok は空
+ reason: string | null;
+}
+
+export interface AuditLog {
+ append(record: AuditRecord): Promise;
+}
+
+// 記録の列の名前(表の列と同じ)。この一覧の外の列を持たない
+export const AUDIT_COLUMNS = [
+ "record_id",
+ "at",
+ "user_id",
+ "credential_id",
+ "via",
+ "operation_id",
+ "account_id",
+ "target_ids",
+ "outcome",
+ "status",
+ "reason",
+] as const;
+
+const ALLOWED: ReadonlySet = new Set(AUDIT_KEYS);
+
+// 許可した鍵の値だけを残す(鍵の名前が一覧に無い値・形の違う値は捨てる)
+export function allowedTargets(targets: readonly string[]): string[] {
+ const out: string[] = [];
+ for (const t of targets) {
+ const i = t.indexOf("=");
+ if (i < 1 || !ALLOWED.has(t.slice(0, i))) continue;
+ if (!out.includes(t)) out.push(t);
+ }
+ return out;
+}
+
+export function toItem(r: AuditRecord): Record<(typeof AUDIT_COLUMNS)[number], unknown> {
+ return {
+ record_id: r.recordId,
+ at: r.at,
+ user_id: r.userId,
+ credential_id: r.credentialId,
+ via: r.via,
+ operation_id: r.operationId,
+ account_id: r.accountId,
+ target_ids: allowedTargets(r.targetIds),
+ outcome: r.outcome,
+ status: r.status,
+ reason: r.reason,
+ };
+}
diff --git a/factory/api/src/audit/memory-log.ts b/factory/api/src/audit/memory-log.ts
new file mode 100644
index 000000000..1d33543d0
--- /dev/null
+++ b/factory/api/src/audit/memory-log.ts
@@ -0,0 +1,14 @@
+// DynamoAuditLog と同じく追記だけを行う、メモリの上の記録。テストと手元の確かめに使う
+import { toItem, type AuditLog, type AuditRecord } from "./log.ts";
+
+export class MemoryAuditLog implements AuditLog {
+ readonly items: ReturnType[] = [];
+ // 真なら追記に失敗する(503 の確かめ)
+ fail = false;
+
+ async append(record: AuditRecord): Promise {
+ if (this.fail) throw new Error("audit down");
+ if (this.items.some((i) => i.record_id === record.recordId)) throw new Error("record exists");
+ this.items.push(toItem(record));
+ }
+}
diff --git a/factory/api/src/audit/middleware.ts b/factory/api/src/audit/middleware.ts
new file mode 100644
index 000000000..ab9e478ff
--- /dev/null
+++ b/factory/api/src/audit/middleware.ts
@@ -0,0 +1,87 @@
+// 操作の記録の書き手(#2384 の F6)。利用者向けの要求 1 回ごとに、結果(成功・一部の失敗・400・401・403・404)によらず
+// 記録を 1 件追記する。追記に失敗したら記録の全体を構造化ログへ出し、応答を 503 audit_unavailable にする(決定 11・I7)
+import { randomUUID } from "node:crypto";
+import type { Context, MiddlewareHandler } from "hono";
+import { OPERATIONS, findOperation, type Operation } from "@ndf-factory/contracts/operations";
+import { isMcpInternal, type AuthEnv } from "../auth/resolve.ts";
+import { ServiceUnavailableError } from "../errors.ts";
+import { toItem, type AuditLog, type AuditOutcome, type AuditRecord } from "./log.ts";
+
+// MCP の受け口そのものへの要求は操作ではないため記録しない(内部の要求をそれぞれ記録する)
+export const MCP_PATH = "/mcp";
+
+const PATTERNS = OPERATIONS.map((op) => ({
+ op: op as Operation,
+ re: new RegExp(`^${op.path.replace("{accountId}", "([^/]+)")}$`),
+}));
+
+// 操作の登録の層を通らなかった要求(401 など)の操作を、メソッドと経路から引く
+function match(method: string, path: string): { op: Operation; accountId: string | null } | null {
+ for (const { op, re } of PATTERNS) {
+ if (op.method !== method) continue;
+ const m = re.exec(path);
+ if (m) return { op, accountId: m[1] ? decodeURIComponent(m[1]) : null };
+ }
+ return null;
+}
+
+// API Gateway の要求の ID(Lambda の環境)。無ければ UUID
+export function requestIdOf(c: Context): string {
+ const env = c.env as { event?: { requestContext?: { requestId?: unknown } } } | undefined;
+ const id = env?.event?.requestContext?.requestId;
+ return typeof id === "string" && id !== "" ? id : randomUUID();
+}
+
+async function recordOf(c: Context, at: string): Promise {
+ const caller = c.get("caller");
+ const trace = c.get("operation");
+ // 呼び出し元の種類は資格情報の種類と内部の印だけで決め、外から届くほかのヘッダーを見ない(I16)
+ const delegated = (c.req.header("Authorization") ?? "").startsWith("Bearer ndfd_");
+ const via = caller?.via ?? (isMcpInternal(c.env) ? "mcp" : delegated ? "api" : "screen");
+ const matched = trace ? { op: findOperation(trace.operationId), accountId: trace.accountId } : match(c.req.method, c.req.path);
+ const status = c.res.status;
+ let outcome: AuditOutcome = "ok";
+ let reason: string | null = null;
+ if (status !== 200) {
+ outcome = "rejected";
+ const body = (await c.res
+ .clone()
+ .json()
+ .catch(() => null)) as { error?: { code?: unknown } } | null;
+ reason = typeof body?.error?.code === "string" ? body.error.code : null;
+ } else if (trace && trace.reasons.length > 0) {
+ outcome = "partial";
+ reason = trace.reasons.join(",");
+ }
+ return {
+ recordId: c.get("requestId") ?? requestIdOf(c),
+ at,
+ userId: caller?.userId ?? null,
+ credentialId: caller?.delegation?.credentialId ?? null,
+ via,
+ operationId: matched?.op?.operationId ?? null,
+ accountId: matched?.accountId ?? null,
+ targetIds: trace?.targetIds ?? [],
+ outcome,
+ status,
+ reason,
+ };
+}
+
+export function recordOperations(log: () => AuditLog | null | undefined, fallback: (line: Record) => void): MiddlewareHandler {
+ return async (c, next) => {
+ const at = new Date().toISOString();
+ await next();
+ if (c.req.method === "OPTIONS" || c.req.path === MCP_PATH) return;
+ const writer = log();
+ if (!writer) return;
+ const record = await recordOf(c, at);
+ try {
+ await writer.append(record);
+ } catch {
+ fallback({ audit_fallback: true, record: toItem(record) });
+ const err = new ServiceUnavailableError("audit_unavailable");
+ c.res = new Response(JSON.stringify(err.body()), { status: 503, headers: { "Content-Type": "application/json" } });
+ }
+ };
+}
diff --git a/factory/api/src/audit/structured-log.ts b/factory/api/src/audit/structured-log.ts
new file mode 100644
index 000000000..f17970689
--- /dev/null
+++ b/factory/api/src/audit/structured-log.ts
@@ -0,0 +1,11 @@
+// 操作の記録の表が無い間(インフラが AUDIT_TABLE_NAME を渡す前)の書き手。記録を構造化ログへ 1 行ずつ出し、
+// 後から表へ入れ直せるようにする
+import { toItem, type AuditLog, type AuditRecord } from "./log.ts";
+
+export class StructuredLogAudit implements AuditLog {
+ constructor(private readonly write: (line: string) => void = (line) => console.log(line)) {}
+
+ async append(record: AuditRecord): Promise {
+ this.write(JSON.stringify({ audit: toItem(record) }));
+ }
+}
diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts
index f374eda8a..3d19932cf 100644
--- a/factory/api/src/auth/resolve.ts
+++ b/factory/api/src/auth/resolve.ts
@@ -1,6 +1,7 @@
import type { Context, MiddlewareHandler } from "hono";
import type { AccountService } from "../accounts/service.ts";
import type { AccountContext, Caller } from "../accounts/model.ts";
+import type { AuditLog } from "../audit/log.ts";
import type { DelegationService } from "../delegations/service.ts";
import { AuthError } from "../errors.ts";
import { verifyAuthorization, type AccessTokenVerifier } from "./verify.ts";
@@ -10,6 +11,8 @@ export interface AuthDeps {
accounts: AccountService;
// 委任の資格情報の照合(#2384)。無ければ委任の資格情報は 401
delegations?: DelegationService | null;
+ // 操作の記録の書き手(#2384)。無ければ記録しない(テストで記録を見ないとき)
+ audit?: AuditLog | null;
}
// MCP の受け口が内部の要求に付ける印(app.request の第 3 引数の環境に置く)。外から届くヘッダーでは決めない(決定 13・I16)
@@ -47,7 +50,7 @@ export interface OperationTrace {
reasons: string[];
}
-export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps; operation?: OperationTrace } };
+export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps; operation?: OperationTrace; requestId?: string } };
// 経路の前に token を毎回検証し、利用者を文脈に置く中間処理(E3)。
// #2171・#2186・#2174 の経路も同じ中間処理を通す
diff --git a/factory/api/src/auth/verify.test.ts b/factory/api/src/auth/verify.test.ts
index 41fd4293b..33706d1ec 100644
--- a/factory/api/src/auth/verify.test.ts
+++ b/factory/api/src/auth/verify.test.ts
@@ -45,6 +45,6 @@ describe("token の検証(受け入れ条件 4)", () => {
await api.call("GET", "/me", { authorization: auth });
const text = JSON.stringify(api.logs);
expect(text).not.toContain(auth.slice("Bearer ".length));
- expect(api.logs.at(-1)).toEqual({ user_id: "u-3", method: "GET", path: "/me", status: 200 });
+ expect(api.logs.at(-1)).toEqual({ request_id: expect.any(String), user_id: "u-3", method: "GET", path: "/me", status: 200 });
});
});
diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts
index d4c73c04c..f4f1781eb 100644
--- a/factory/api/src/handlers/api.ts
+++ b/factory/api/src/handlers/api.ts
@@ -12,10 +12,15 @@ import { registerAccounts } from "../routes/accounts.ts";
import { registerDelegations } from "../routes/delegations.ts";
import { DelegationService } from "../delegations/service.ts";
import { DynamoDelegationStore } from "../delegations/store.ts";
+import { recordOperations, requestIdOf } from "../audit/middleware.ts";
+import { DynamoAuditLog } from "../audit/dynamo-log.ts";
+import { StructuredLogAudit } from "../audit/structured-log.ts";
import { registerFactory, type FactoryDeps } from "../routes/factory.ts";
import { factoryDepsFromEnv } from "../runs/deps.ts";
export interface LogEntry {
+ // 操作の記録の record_id と同じ値(同じ要求の記録とログの行をたどる)
+ request_id: string;
user_id: string | null;
method: string;
path: string;
@@ -47,8 +52,10 @@ export function createApiApp(
): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
+ c.set("requestId", requestIdOf(c));
await next();
log({
+ request_id: c.get("requestId")!,
user_id: c.get("caller")?.userId ?? null,
method: c.req.method,
path: c.req.path,
@@ -64,6 +71,8 @@ export function createApiApp(
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
}),
);
+ // 操作の記録は認証より外に置き、401 で断った要求も記録する(I7)
+ app.use("*", recordOperations(() => deps().audit, (line) => console.log(JSON.stringify(line))));
app.use("*", requireCaller(deps));
app.onError((err, c) => {
const known = err instanceof ApiError ? err : new AccountUnavailableError();
@@ -95,6 +104,8 @@ export function depsFromEnv(): AuthDeps {
accounts: new AccountService({ store: new DynamoAccountStore(requireEnv("ACCOUNTS_TABLE_NAME")) }),
// 委任の資格情報の表(インフラが DELEGATIONS_TABLE_NAME で渡す)。無い間は委任の資格情報を 401 にし、委任の操作を 503 にする
delegations: process.env.DELEGATIONS_TABLE_NAME ? new DelegationService({ store: new DynamoDelegationStore(process.env.DELEGATIONS_TABLE_NAME) }) : null,
+ // 操作の記録の表(インフラが AUDIT_TABLE_NAME で渡す)。無い間は記録を構造化ログへ出す
+ audit: process.env.AUDIT_TABLE_NAME ? new DynamoAuditLog(process.env.AUDIT_TABLE_NAME) : new StructuredLogAudit(),
};
return cached;
}
diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts
index d093aa207..292ba14be 100644
--- a/factory/api/src/testing/app.ts
+++ b/factory/api/src/testing/app.ts
@@ -2,6 +2,7 @@ import { operation, operationUrl, type OperationId } from "@ndf-factory/contract
import { AccountService } from "../accounts/service.ts";
import { MemoryAccountStore } from "../accounts/memory-store.ts";
import { MemoryDelegationStore } from "../delegations/memory-store.ts";
+import { MemoryAuditLog } from "../audit/memory-log.ts";
import { DelegationService } from "../delegations/service.ts";
import { createApiApp, type LogEntry } from "../handlers/api.ts";
import { createTokenKit, type TokenKit } from "./tokens.ts";
@@ -61,6 +62,7 @@ export interface TestApi {
store: MemoryAccountStore;
service: AccountService;
delegationStore: MemoryDelegationStore;
+ audit: MemoryAuditLog;
delegations: DelegationService;
// 時刻を進められる時計(委任の期限の確かめ)
clock: { now: Date };
@@ -81,6 +83,7 @@ export async function createTestApi(opts: { factory?: boolean } = {}): Promise clock.now });
const secrets = new FakeSecrets();
const h = opts.factory ? await createRunsHarness() : null;
@@ -100,7 +103,7 @@ export async function createTestApi(opts: { factory?: boolean } = {}): Promise ({ verifier: tokens.verifier, accounts: service, delegations }),
+ () => ({ verifier: tokens.verifier, accounts: service, delegations, audit }),
(e) => logs.push(e),
() => null,
factory && (() => factory!),
@@ -110,6 +113,7 @@ export async function createTestApi(opts: { factory?: boolean } = {}): Promise
Date: Wed, 4 Nov 2026 22:46:38 +0000
Subject: [PATCH 06/16] =?UTF-8?q?MCP=20=E3=81=AE=E5=8F=97=E3=81=91?=
=?UTF-8?q?=E5=8F=A3=E3=82=92=E8=B6=B3=E3=81=97=E3=80=81=E3=83=84=E3=83=BC?=
=?UTF-8?q?=E3=83=AB=E3=81=AE=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E3=82=92?=
=?UTF-8?q?=E5=90=8C=E3=81=98=E3=82=A2=E3=83=97=E3=83=AA=E3=81=AE=E7=B5=8C?=
=?UTF-8?q?=E8=B7=AF=E3=81=B8=E9=80=81=E3=82=8B=EF=BC=88#2384=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
POST /mcp で MCP の要求を状態を持たずに受け(@modelcontextprotocol/server 2.4 の Web 標準の受け口)、
操作の表から作ったツールの呼び出しを、委任の資格情報を付けたまま同じアプリへ内部の要求として送る。
認証・範囲の照合・要素の検査・認可・記録は API と同じ処理を通り、記録の呼び出し元の種類は mcp になる。
受け口は委任の資格情報だけを受け、画面と違うオリジンは 403、GET と DELETE は 405 にする。
Co-Authored-By: Claude Opus 5.5
---
factory/api/package.json | 1 +
factory/api/src/handlers/api.ts | 3 +
factory/api/src/mcp/mcp.test.ts | 116 ++++++++++++++++++++++++++++++++
factory/api/src/mcp/server.ts | 93 +++++++++++++++++++++++++
factory/package-lock.json | 35 ++++++++++
5 files changed, 248 insertions(+)
create mode 100644 factory/api/src/mcp/mcp.test.ts
create mode 100644 factory/api/src/mcp/server.ts
diff --git a/factory/api/package.json b/factory/api/package.json
index dda05bd68..38c107d07 100644
--- a/factory/api/package.json
+++ b/factory/api/package.json
@@ -17,6 +17,7 @@
"@aws-sdk/lib-dynamodb": "^3.1160.0",
"@aws-sdk/s3-request-presigner": "^3.1161.0",
"@aws-sdk/util-dynamodb": "^3.996.9",
+ "@modelcontextprotocol/server": "^2.4.0",
"@ndf-factory/contracts": "*",
"aws-jwt-verify": "^5.2.1",
"hono": "^4.13.14",
diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts
index f4f1781eb..d6b306326 100644
--- a/factory/api/src/handlers/api.ts
+++ b/factory/api/src/handlers/api.ts
@@ -15,6 +15,7 @@ import { DynamoDelegationStore } from "../delegations/store.ts";
import { recordOperations, requestIdOf } from "../audit/middleware.ts";
import { DynamoAuditLog } from "../audit/dynamo-log.ts";
import { StructuredLogAudit } from "../audit/structured-log.ts";
+import { registerMcp } from "../mcp/server.ts";
import { registerFactory, type FactoryDeps } from "../routes/factory.ts";
import { factoryDepsFromEnv } from "../runs/deps.ts";
@@ -83,6 +84,8 @@ export function createApiApp(
registerAccounts(app);
registerDelegations(app);
if (factory) registerFactory(app, factory);
+ // MCP の受け口。依存は同じアプリへの内部の要求だけ(#2384 の I15)
+ registerMcp(app, (path, init, env) => app.request(path, init, env), webOrigin);
return app;
}
diff --git a/factory/api/src/mcp/mcp.test.ts b/factory/api/src/mcp/mcp.test.ts
new file mode 100644
index 000000000..afa9e92c4
--- /dev/null
+++ b/factory/api/src/mcp/mcp.test.ts
@@ -0,0 +1,116 @@
+// MCP の受け口(#2384 の受け入れ条件 2・3・17・18、不変条件 I11・I15・I16)
+import { Hono } from "hono";
+import { beforeEach, describe, expect, it } from "vitest";
+import { OPERATIONS } from "@ndf-factory/contracts/operations";
+import type { AuthEnv } from "../auth/resolve.ts";
+import { createTestApi, firstResult, type TestApi } from "../testing/app.ts";
+import { registerMcp, tools, type AppRequest } from "./server.ts";
+
+let api: TestApi;
+let credential: string;
+let account: string;
+let id = 0;
+
+beforeEach(async () => {
+ api = await createTestApi({ factory: true });
+ account = (await (await api.op("getMe", { user: "u-1" })).json())[0].personal_account_id;
+ const issued = await firstResult(
+ await api.op("createDelegation", { user: "u-1", items: [{ client_name: "c", scope: "read_write", expires_in_seconds: 3600, idempotency_key: "m" }] }),
+ );
+ credential = issued.body.credential;
+});
+
+async function rpc(method: string, params: unknown, authorization = `Bearer ${credential}`, headers: Record = {}) {
+ const res = await api.app.request("/mcp", {
+ method: "POST",
+ headers: { Authorization: authorization, "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...headers },
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
+ });
+ return { status: res.status, body: (await res.json().catch(() => null)) as any };
+}
+
+const callTool = async (name: string, args: Record) => (await rpc("tools/call", { name, arguments: args })).body.result;
+
+describe("ツールと契約の対応(受け入れ条件 2、I11)", () => {
+ it("tools/list のツールの名前の集合が、契約の利用者向けの操作と一致する", async () => {
+ const { status, body } = await rpc("tools/list", {});
+ expect(status).toBe(200);
+ expect(body.result.tools.map((t: { name: string }) => t.name).sort()).toEqual(OPERATIONS.map((o) => o.operationId).sort());
+ });
+
+ it("人だけが確定できるツールは説明の頭で断られることを示し、入力は操作の形を持つ", () => {
+ const byName = new Map(tools().map((t) => [t.name, t]));
+ expect(byName.get("answerLoopPending")!.description).toMatch(/^人だけが確定できる/);
+ expect(byName.get("startSprintRun")!.inputSchema.required).toEqual(["account_id", "items"]);
+ expect(byName.get("getSprintRunDetail")!.inputSchema.properties.sprint_run_id).toMatchObject({ type: "array", maxItems: 100 });
+ expect(byName.get("getMe")!.inputSchema.required).toEqual([]);
+ });
+});
+
+describe("ツールの呼び出し(受け入れ条件 3・17・18、I15・I16)", () => {
+ it("API の経路と同じ結果を返し、操作の記録に mcp として残る。tools/list は記録しない", async () => {
+ const direct = await (await api.op("listSprintRuns", { authorization: `Bearer ${credential}`, accountId: account })).json();
+ const before = api.audit.items.length;
+ await rpc("tools/list", {});
+ expect(api.audit.items.length).toBe(before);
+ const r = await callTool("listSprintRuns", { account_id: account });
+ expect(r.structuredContent).toEqual({ status: 200, body: direct });
+ expect(r.isError).toBe(false);
+ expect(JSON.parse(r.content[0].text)).toEqual(r.structuredContent);
+ expect(api.audit.items.at(-1)).toMatchObject({ operation_id: "listSprintRuns", via: "mcp", user_id: "u-1", outcome: "ok" });
+ expect(api.audit.items.length).toBe(before + 1);
+ });
+
+ it("人だけが確定できるツールは 403 human_only を返し、答えも状態も保存しない", async () => {
+ const r = await callTool("answerLoopPending", { account_id: account, items: [{ loop_id: "l", issue: 1, decision: "approved" }] });
+ expect(r.isError).toBe(true);
+ expect(r.structuredContent).toMatchObject({ status: 403, body: { error: { code: "human_only" } } });
+ expect(api.h!.table.loops.size).toBe(0);
+ });
+
+ it("承認の依頼の材料(スプリントの詳細)は読める。要素の結果はそのまま返る", async () => {
+ const r = await callTool("getSprintRunDetail", { account_id: account, sprint_run_id: ["sr-none"] });
+ expect(r.structuredContent.status).toBe(200);
+ expect(r.structuredContent.body[0]).toMatchObject({ ok: false, error: { code: "forbidden" } });
+ });
+
+ it("アカウントが要るツールに account_id が無ければ 400 で、アプリへ送らない", async () => {
+ const r = await callTool("listSprintRuns", {});
+ expect(r.structuredContent.status).toBe(400);
+ });
+
+ it("受け口が受け取る依存はアプリへの要求の関数だけで、ツールの呼び出しはすべてそこを通る", async () => {
+ const seen: string[] = [];
+ const app = new Hono();
+ app.use("*", async (c, next) => {
+ c.set("caller", { userId: "u", idp: "google", delegation: { credentialId: "x", scope: "read" }, via: "api" });
+ await next();
+ });
+ const call: AppRequest = async (path, init) => {
+ seen.push(`${init.method} ${path}`);
+ return Response.json([]);
+ };
+ registerMcp(app, call, () => null);
+ const res = await app.request("/mcp", {
+ method: "POST",
+ headers: { Authorization: "Bearer ndfd_x", "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "listLoops", arguments: { account_id: "a", repository_id: 1 } } }),
+ });
+ expect(res.status).toBe(200);
+ expect(seen).toEqual(["GET /accounts/a/loops?repository_id=1"]);
+ });
+});
+
+describe("受け口の資格情報(決定 7)", () => {
+ it("Cognito の token は 401、資格情報が無ければ 401", async () => {
+ expect((await rpc("tools/list", {}, await api.tokens.bearer("u-1"))).status).toBe(401);
+ expect((await rpc("tools/list", {}, "")).status).toBe(401);
+ });
+
+ it("画面と違うオリジンからの要求は 403、GET と DELETE は 405", async () => {
+ expect((await rpc("tools/list", {}, `Bearer ${credential}`, { Origin: "https://evil.example.com" })).status).toBe(403);
+ for (const method of ["GET", "DELETE"]) {
+ expect((await api.app.request("/mcp", { method, headers: { Authorization: `Bearer ${credential}` } })).status).toBe(405);
+ }
+ });
+});
diff --git a/factory/api/src/mcp/server.ts b/factory/api/src/mcp/server.ts
new file mode 100644
index 000000000..40c102131
--- /dev/null
+++ b/factory/api/src/mcp/server.ts
@@ -0,0 +1,93 @@
+// MCP の受け口(#2384 の F4・決定 6・7・14)。POST /mcp で MCP の要求を受け、操作の表から作ったツールの呼び出しを、
+// 同じアプリの経路へ内部の要求として送る。認証・範囲の照合・要素の検査・認可・記録は画面と API と同じ処理を通る(I15)。
+// 状態を持たず、要求ごとに JSON の 1 応答で返す。委任の資格情報だけを受ける
+import type { Hono } from "hono";
+import { Server, WebStandardStreamableHTTPServerTransport, type Tool } from "@modelcontextprotocol/server";
+import { MAX_ITEMS, OPERATIONS, findOperation, operationUrl, type Operation } from "@ndf-factory/contracts/operations";
+import { MCP_INTERNAL, type AuthEnv } from "../auth/resolve.ts";
+import { AuthError, ForbiddenError } from "../errors.ts";
+import { MCP_PATH } from "../audit/middleware.ts";
+
+// アプリへ内部の要求を送る関数(Hono の app.request)。MCP の受け口が受け取る依存はこれだけ
+export type AppRequest = (path: string, init: RequestInit, env: Record) => Response | Promise;
+
+export const SERVER_INFO = { name: "ndf-factory", version: "0.1.0" } as const;
+
+const HUMAN_NOTE = "人だけが確定できる。MCP からは常に断られる。画面で行う。";
+
+const BODY_KINDS = new Set(["create", "update", "action"]);
+
+export interface ToolDefinition {
+ name: string;
+ description: string;
+ inputSchema: { type: "object"; properties: Record; required: string[] };
+}
+
+// ツールの定義を操作の表から作る。手で書いたツールの定義を持たない(契約の表の「MCP のツール」)
+export function toolOf(op: Operation): ToolDefinition {
+ const properties: Record = {};
+ const required: string[] = [];
+ if (op.path.includes("{accountId}")) {
+ properties.account_id = { type: "string", description: "アカウントの ID" };
+ required.push("account_id");
+ }
+ if (BODY_KINDS.has(op.kind)) {
+ properties.items = { type: "array", minItems: 1, maxItems: MAX_ITEMS, items: op.itemSchema ?? { type: "object" }, description: "要素の配列。結果は同じ順に返る" };
+ required.push("items");
+ } else {
+ for (const p of op.parameters) {
+ properties[p.name] = { ...(p.schema as object), ...(p.description ? { description: p.description } : {}) };
+ if (p.required) required.push(p.name);
+ }
+ }
+ const text = [op.summary, op.description].filter((s) => s !== "").join("\n");
+ return { name: op.operationId, description: op.access === "human" ? `${HUMAN_NOTE}\n${text}` : text, inputSchema: { type: "object", properties, required } };
+}
+
+export const tools = (): ToolDefinition[] => OPERATIONS.map((op) => toolOf(op as Operation));
+
+function result(status: number, body: unknown) {
+ const value = { status, body };
+ return { content: [{ type: "text" as const, text: JSON.stringify(value) }], structuredContent: value, isError: status >= 400 };
+}
+
+// ツールの入力から経路・query・本文を組み、Authorization をそのまま付けて内部の要求として送る
+async function callTool(call: AppRequest, authorization: string, name: string, args: Record) {
+ const op = findOperation(name);
+ if (!op) return result(400, { error: { code: "unknown_tool", message: `${name} というツールはありません` } });
+ const accountId = typeof args.account_id === "string" ? args.account_id : null;
+ if (op.path.includes("{accountId}") && accountId === null) return result(400, { error: { code: "invalid_request", message: "account_id が要ります" } });
+ const query: Record = {};
+ if (!BODY_KINDS.has(op.kind)) for (const p of op.parameters) if (args[p.name] !== undefined) query[p.name] = args[p.name];
+ const hasBody = BODY_KINDS.has(op.kind);
+ const res = await call(
+ operationUrl(op, accountId, query),
+ {
+ method: op.method,
+ headers: { Authorization: authorization, ...(hasBody ? { "Content-Type": "application/json" } : {}) },
+ body: hasBody ? JSON.stringify(args.items ?? null) : undefined,
+ },
+ { [MCP_INTERNAL]: true },
+ );
+ return result(res.status, await res.json().catch(() => null));
+}
+
+export function registerMcp(app: Hono, call: AppRequest, webOrigin: () => string | null): void {
+ app.post(MCP_PATH, async (c) => {
+ // 委任の資格情報だけを受ける(Cognito の token は 401)
+ if (!c.get("caller").delegation) throw new AuthError();
+ // ブラウザからの要求は画面のオリジンだけを受ける(Streamable HTTP の Origin の検査)
+ const origin = c.req.header("Origin");
+ if (origin !== undefined && origin !== webOrigin()) throw new ForbiddenError();
+ const authorization = c.req.header("Authorization")!;
+ const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
+ // 定義は契約から作った JSON Schema(JSON の値だけを持つ)
+ server.setRequestHandler("tools/list", async () => ({ tools: tools() as unknown as Tool[] }));
+ server.setRequestHandler("tools/call", async (req) => callTool(call, authorization, req.params.name, (req.params.arguments ?? {}) as Record));
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
+ await server.connect(transport);
+ return transport.handleRequest(c.req.raw);
+ });
+ // 状態を持たないため、GET のストリームとセッションの終わりは受けない
+ for (const method of ["get", "delete"] as const) app[method](MCP_PATH, (c) => c.body(null, 405));
+}
diff --git a/factory/package-lock.json b/factory/package-lock.json
index 001c792b7..43c648eb2 100644
--- a/factory/package-lock.json
+++ b/factory/package-lock.json
@@ -29,6 +29,7 @@
"@aws-sdk/lib-dynamodb": "^3.1160.0",
"@aws-sdk/s3-request-presigner": "^3.1161.0",
"@aws-sdk/util-dynamodb": "^3.996.9",
+ "@modelcontextprotocol/server": "^2.4.0",
"@ndf-factory/contracts": "*",
"aws-jwt-verify": "^5.2.1",
"hono": "^4.13.14",
@@ -1063,6 +1064,31 @@
"jsep": "^0.4.0||^1.0.0"
}
},
+ "node_modules/@modelcontextprotocol/core": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz",
+ "integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@modelcontextprotocol/server": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz",
+ "integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@modelcontextprotocol/core": "2.4.0",
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@ndf-factory/api": {
"resolved": "api",
"link": true
@@ -7455,6 +7481,15 @@
"node": ">=12"
}
},
+ "node_modules/zod": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"runner": {
"name": "@ndf-factory/runner",
"dependencies": {
From 5bbce773ee8954d87bfc4557e16487d8a52f825a Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 23:06:38 +0000
Subject: [PATCH 07/16] =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?=
=?UTF-8?q?=E3=81=AE=E7=94=BB=E9=9D=A2=E5=90=91=E3=81=91=E3=81=AE=E7=B5=8C?=
=?UTF-8?q?=E8=B7=AF=E3=82=92=E3=80=81=E8=A6=81=E7=B4=A0=E3=81=AE=E9=85=8D?=
=?UTF-8?q?=E5=88=97=E3=81=A7=E5=8F=97=E3=81=91=E6=B8=A1=E3=81=99=E6=93=8D?=
=?UTF-8?q?=E4=BD=9C=E3=81=AE=E5=BD=A2=E3=81=B8=E5=90=88=E3=82=8F=E3=81=9B?=
=?UTF-8?q?=E3=82=8B=EF=BC=88#2384=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
.../ndf-factory-accounts-and-plans.md | 24 +++++++-------
...df-factory-dev-loops-data-and-contracts.md | 22 ++++++-------
...df-factory-organizer-data-and-contracts.md | 10 +++---
.../ndf-factory-runs-and-runner.md | 32 +++++++++----------
.../ndf-factory-service-boundary.md | 16 ++++++++++
.../ndf-factory-sprint-views.md | 14 ++++----
.../ndf-factory-ui-components.md | 4 +--
7 files changed, 71 insertions(+), 51 deletions(-)
diff --git a/docs/specifications/ndf-factory-accounts-and-plans.md b/docs/specifications/ndf-factory-accounts-and-plans.md
index 9f5f5006f..055d62680 100644
--- a/docs/specifications/ndf-factory-accounts-and-plans.md
+++ b/docs/specifications/ndf-factory-accounts-and-plans.md
@@ -108,7 +108,7 @@ graph TD
あわせて `LIMITS_TABLE`・`AccountService`・エラーの型(`ApiError`・`AuthError`・`ForbiddenError`・`PlanError`)と、`AccountContext`・`Caller`・`ExecutionMode`・`Plan`・`SeatType`・`Limits`・`Judgment`・`CurrentUsage`・`LimitReason`・`AuthEnv`・`ApiApp` の型を export する。
-**使える量は HTTP の経路にしない。** #2186・#2174 の処理は同じ `factory/api/` の中で上の関数を呼ぶ。画面は `GET /accounts/{account_id}` の `limits` で上限を出す。
+**使える量は HTTP の経路にしない。** #2186・#2174 の処理は同じ `factory/api/` の中で上の関数を呼ぶ。画面は `GET /accounts?account_id=` の `limits` で上限を出す。
### token の検証とアカウントの解決
@@ -214,22 +214,22 @@ sequenceDiagram
### API の経路
-経路はすべて `Authorization: Bearer ` を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。
+経路はすべて `Authorization: Bearer `(または委任の資格情報)を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である(401 は要求全体)。チームの作成と席の追加・変更・削除は人だけが確定でき、委任の資格情報では要求全体を 403 `human_only` で断る。
-| 経路 | 入力 | 成功 | 失敗 |
+| 経路 | 要素・条件 | 値 | 失敗 |
| --- | --- | --- | --- |
-| `GET /me` | — | 200 `{user_id, idp, personal_account_id, accounts: [{account_id, kind, name, plan, role, seat_type, execution_mode}]}`。先頭は個人のアカウント。初回は個人のアカウントを作る | 401・503 `account_unavailable` |
-| `GET /accounts/{account_id}` | — | 200 `{account_id, kind, name, plan, role, seat_type, execution_mode, limits: {concurrent_sprint_runs, monthly_tokens}, seats}`。`monthly_tokens` は `managed` のときだけ数で、ほかは `null`。`seats` はチームだけで `[{user_id, idp, seat_type, added_at}]`、個人では `null` | 401・403 `forbidden` |
-| `POST /teams` | `{name: 1〜64 字(空白だけは不可), seat_type}` | 201 アカウントの本文(上と同じ形)。作った利用者が持ち主で、その席が 1 つある | 400 `invalid_request` |
-| `PUT /accounts/{account_id}/execution-mode` | `{execution_mode: "local" \| "byok" \| "managed"}` | 200 アカウントの本文。今と同じ値なら履歴の行を足さずに 200 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free の料金プランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` |
-| `POST /accounts/{account_id}/seats` | `{user_id: 1〜128 字, seat_type}` | 201 席 `{user_id, idp, seat_type, added_at}` | 400・403・409 `not_a_team`・422 `user_not_found`・409 `seat_exists` |
-| `PATCH /accounts/{account_id}/seats/{user_id}` | `{seat_type}` | 200 席 | 400・403・409 `not_a_team`・404 `seat_not_found`・503 |
-| `DELETE /accounts/{account_id}/seats/{user_id}` | — | 204 | 403・409 `not_a_team`・409 `owner_seat_protected`・404 `seat_not_found`・503 |
+| `GET /me` | — | `{user_id, idp, personal_account_id, accounts: [{account_id, kind, name, plan, role, seat_type, execution_mode}]}`。先頭は個人のアカウント。初回は個人のアカウントを作る | 401・503 `account_unavailable` |
+| `GET /accounts?account_id=` | 鍵 `account_id`(要素ごとにアカウントを解決する) | `{account_id, kind, name, plan, role, seat_type, execution_mode, limits: {concurrent_sprint_runs, monthly_tokens}, seats}`。`monthly_tokens` は `managed` のときだけ数で、ほかは `null`。`seats` はチームだけで `[{user_id, idp, seat_type, added_at}]`、個人では `null` | 401・403 `forbidden` |
+| `POST /teams` | `{name: 1〜64 字(空白だけは不可), seat_type, idempotency_key}` | アカウントの本文(上と同じ形)。作った利用者が持ち主で、その席が 1 つある | 400 `invalid_request` |
+| `PATCH /accounts` | `{account_id, execution_mode: "local" \| "byok" \| "managed"}`(要素ごとにアカウントを解決する) | アカウントの本文。今と同じ値なら履歴の行を足さずに成功 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free の料金プランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` |
+| `POST /accounts/{account_id}/seats` | `{user_id: 1〜128 字, seat_type}` | 席 `{user_id, idp, seat_type, added_at}` | 400・403・409 `not_a_team`・422 `user_not_found`・409 `seat_exists` |
+| `PATCH /accounts/{account_id}/seats` | `{user_id, seat_type}` | 席 | 400・403・409 `not_a_team`・404 `seat_not_found`・503 |
+| `DELETE /accounts/{account_id}/seats?user_id=` | 鍵 `user_id` | `{removed: true}` | 403・409 `not_a_team`・409 `owner_seat_protected`・404 `seat_not_found`・503 |
- **持ち主の確かめは、席の有無と料金プランの照合より先に行う。** 404 `seat_not_found` と 409 `managed_not_allowed_on_free` は、要求した者が持ち主と確かめた後だけ返す
- **`byok` を選んでもキーの登録はこの経路で求めない。** キーの保存は #2186 が持つ
- **`GET /me` のチームの一覧は GSI を候補として引き、元の席を強い整合で確かめる。** 外した席が GSI に残る間に、そのチームの名前や料金プランを返さないためである
-- 本文が JSON のオブジェクトでなければ 400 `invalid_request`。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える
+- 本文が配列でなければ要求全体を、要素がオブジェクトでなければその要素を 400 `invalid_request` にする。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える
### GitHub の仲立ち
@@ -434,7 +434,7 @@ graph TD
| 他人のアカウント(I18) | 利用者 A の token で、他人の個人のアカウント・入っていないチーム・存在しない ID の 3 つに読む・書くを送ると、すべて 403 で本文が同じであること |
| 料金プランの組(I13) | 個人のアカウントへの席の操作は 409、`setPlan` で個人に `team`・チームに `pro` は失敗で、行が変わらないこと |
| 履歴(I20) | 席・料金プラン・実行の形態の変更のたびに履歴の行が 1 つ増え、変更が失敗したときは増えないこと |
-| 実行の形態の読み出し | `GET /me`・`GET /accounts/{account_id}` の応答と `resolveAccount` の文脈に保存した形態が出ること |
+| 実行の形態の読み出し | `GET /me`・`GET /accounts?account_id=` の応答と `resolveAccount` の文脈に保存した形態が出ること |
| 実行の形態の変更(I21・I22) | 持ち主の変更は 200 で形態が変わり、持ち主でない者は 403、Free で `managed` は 409 `managed_not_allowed_on_free` で、どちらも形態と履歴が変わらないこと |
| Free へ下げる(I21) | `managed` のプロのアカウントへ `setPlan(…, "free")` を呼ぶと、料金プランが `free`・形態が `local` になり、2 つの履歴の行が同じトランザクションで増えること。形態の書き込みが失敗すれば料金プランも変わらないこと |
| 版の競合(I21) | `setPlan` の書き込みの前に別の要求が `managed` を保存して版を進めても、`setPlan` が読み直して、終わったとき料金プランが `free`・形態が `local` であること |
diff --git a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md
index 41e3f8e0b..70dc77a8e 100644
--- a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md
+++ b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md
@@ -150,19 +150,19 @@ erDiagram
### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`)
-どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。ループの経路はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found`。エラーの本文は `{"error": {"code", "message", "details"?}}`。
+どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。ループの要素はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found` の要素の結果になる。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。
-| 経路 | 誰が | 入力 | 成功 | 失敗 |
+| 経路 | 誰が | 要素・条件 | 値 | 失敗 |
| --- | --- | --- | --- | --- |
-| `POST /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | `{name, milestones, budget?: {laps?, usd?, hours?}}` | 201 `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) |
-| `GET /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | — | 200 `{loops: [{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}]}`(新しい順 50 件) | 400・403 |
-| `GET /accounts/{a}/loops/{id}` | 席を持つ人 | — | 200 `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 |
-| `PATCH /accounts/{a}/loops/{id}` | 席を持つ人 | `{budget}` | 200 ループの 1 行 | 400・403・404・409 `already_finished` |
-| `POST /accounts/{a}/loops/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` |
-| `POST /accounts/{a}/loops/{id}/next-lap` | 席を持つ人 | — | 202 `{state}` | 403・404・409 `not_awaiting_user_start` |
-| `POST /accounts/{a}/loops/{id}/answers` | 席を持つ人 | `{issue, decision: approved \| rejected, pr?}` | 200 `{pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) |
-| `PATCH /accounts/{a}/repositories/{r}`(`runs.yaml`) | 持ち主 | `{max_concurrent_runs: 1〜100 の整数か null}` | 200 登録の 1 行 | 400・403・404 |
-| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | 既存のまま | 既存のまま | 既存の失敗に加えて 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` |
+| `POST /accounts/{a}/loops` | 席を持つ人 | `{repository_id, name, milestones, budget?: {laps?, usd?, hours?}, idempotency_key}` | `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) |
+| `GET /accounts/{a}/loops?repository_id=` | 席を持つ人 | 条件 `repository_id` | `{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}`(新しい順 50 件) | 要求全体の 400・403 |
+| `GET /accounts/{a}/loops/detail?loop_id=` | 席を持つ人 | 鍵 `loop_id` | `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 |
+| `PATCH /accounts/{a}/loops` | 席を持つ人 | `{loop_id, budget}` | ループの 1 行 | 400・403・404・409 `already_finished` |
+| `POST /accounts/{a}/loops/cancel` | 席を持つ人 | `{loop_id}` | `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` |
+| `POST /accounts/{a}/loops/next-lap` | 席を持つ人 | `{loop_id}` | `{state}` | 403・404・409 `not_awaiting_user_start` |
+| `POST /accounts/{a}/loops/answers` | 席を持つ人(委任の資格情報では断る) | `{loop_id, issue, decision: approved \| rejected, pr?}` | `{loop_id, pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) |
+| `PATCH /accounts/{a}/repositories`(`runs.yaml`) | 持ち主 | `{repository_id, max_concurrent_runs: 1〜100 の整数か null}` | 登録の 1 行 | 400・403・404 |
+| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | [スプリントの実行の経路](ndf-factory-runs-and-runner.md#画面向けの経路) のまま | 同じ | 既存の失敗に加えて 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` |
## 関連リンク
diff --git a/docs/specifications/ndf-factory-organizer-data-and-contracts.md b/docs/specifications/ndf-factory-organizer-data-and-contracts.md
index d39e7c545..44af6d06b 100644
--- a/docs/specifications/ndf-factory-organizer-data-and-contracts.md
+++ b/docs/specifications/ndf-factory-organizer-data-and-contracts.md
@@ -101,11 +101,13 @@ erDiagram
### 画面向けの経路(`factory/contracts/openapi/organizer.yaml`)
-| 経路 | 入力 | 成功 | 失敗の形 |
+入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗の形」は要素の結果の状態である(401 は要求全体)。
+
+| 経路 | 要素・条件 | 値 | 失敗の形 |
| --- | --- | --- | --- |
-| `GET /accounts/{accountId}/repositories/{repositoryId}/approval-requests` | — | 200 `{organizer: {current_seq, wait_reason, wait_detail}, items}`。`items` の 1 件は `{kind, sprint_run_id?, loop_id?, pr_url?, number?, reason?, created_at, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のものだけが持つ `{seq, k, sha, lanes:[{lane, requested, prs}], added_prs, choices}` | 401・403・404 |
-| `GET /accounts/{accountId}/repositories/{repositoryId}/releases` | `?limit`(1〜100、既定 20) | 200 `{items: [{seq, state, release_run_id, presentations, confirmed_k, result, log}]}`(新しい順) | 400・401・403・404 |
-| `POST /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve` | `{choice: "continue" \| "reject", body?}` | 202 `{seq, state}`。`state` は戻した状態(`composed` / `presented` / `represented` / `confirmed`)か `rejected` | 400・401・403・404・409(`not_stalled` / `release_after_main`) |
+| `GET /accounts/{accountId}/approval-requests?repository_id=` | 鍵 `repository_id` | `{repository_id, organizer: {current_seq, wait_reason, wait_detail}, items}`。`items` の 1 件は `{kind, sprint_run_id?, loop_id?, pr_url?, number?, reason?, created_at, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のものだけが持つ `{seq, k, sha, lanes:[{lane, requested, prs}], added_prs, choices}` | 401・403・404 |
+| `GET /accounts/{accountId}/releases?repository_id=` | 条件 `repository_id`(1 つ)と `limit`(1〜100、既定 20) | `{seq, state, release_run_id, presentations, confirmed_k, result, log}`(新しい順) | 要求全体の 400・401・403・404 |
+| `POST /accounts/{accountId}/releases/resolve` | `{repository_id, seq, choice: "continue" \| "reject", body?}`(委任の資格情報では要求全体を 403 `human_only` で断る) | `{seq, state}`。`state` は戻した状態(`composed` / `presented` / `represented` / `confirmed`)か `rejected` | 400・401・403・404・409(`not_stalled` / `release_after_main`) |
一覧と履歴の経路は読むだけで、承認を確定する経路はこのファイルに無い(I66)。
diff --git a/docs/specifications/ndf-factory-runs-and-runner.md b/docs/specifications/ndf-factory-runs-and-runner.md
index 621e8bbf6..a75598c46 100644
--- a/docs/specifications/ndf-factory-runs-and-runner.md
+++ b/docs/specifications/ndf-factory-runs-and-runner.md
@@ -149,23 +149,23 @@ graph TD
### 画面向けの経路
-機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。
+機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力・成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。下の表の「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。
-| 経路 | 誰が | 入力 | 成功 | 失敗 |
+| 経路 | 誰が | 要素・条件 | 値 | 失敗 |
| --- | --- | --- | --- | --- |
-| `GET /accounts/{a}/github/install-url` | 席を持つ人 | — | 200 `{url}`(`https://github.com/apps//installations/new`) | 403 |
-| `GET /accounts/{a}/github/authorize-url` | 持ち主 | — | 200 `{url}`(`https://github.com/login/oauth/authorize?client_id=&state=`) | 403 |
-| `POST /accounts/{a}/github/candidates` | 持ち主 | `{code, state}`(認可の戻り) | 200 `{proof, candidates: [{repository_id, full_name, installation_id, registered}]}` | 400 `invalid_request`(`state` が合わない・`code` が無いか換えられない)・403 |
-| `GET /accounts/{a}/repositories` | 席を持つ人 | — | 200 `{repositories: [{repository_id, full_name, installation_id, registered_at, active_sprint_runs}]}` | 403 |
-| `POST /accounts/{a}/repositories` | 持ち主 | `{proof, repository_id}` | 201 登録の 1 行(`active_sprint_runs` を除く) | 400(証明の署名・期限・利用者・アカウントの違い)・403(証明に無い ID、登録の直前に導入の token を作れない。I33)・409 `repository_exists`(I32) |
-| `DELETE /accounts/{a}/repositories/{repositoryId}` | 持ち主 | — | 204 | 400(ID の形)・403・404 `repository_not_found`・409 `repository_in_use`(I36) |
-| `GET /accounts/{a}/model-credential` | 持ち主 | — | 200 `{registered, kind, updated_at, external_id}`。未登録なら `kind`・`updated_at` は `null`。`external_id` は常に返す(秘密でない) | 403 |
-| `PUT /accounts/{a}/model-credential` | 持ち主 | `{kind: "anthropic_api_key", api_key}`(1〜512 字)か `{kind: "aws_role", role_arn, region}`(`^arn:aws[a-z-]*:iam::\d{12}:role/.+$` で 2,048 字まで、`^[a-z]{2}(-[a-z]+)+-\d$` で 32 字まで) | 200 上と同じ形(値も ARN も返さない) | 400・403・503 `credential_unavailable` |
-| `DELETE /accounts/{a}/model-credential` | 持ち主 | — | 204(無くても 204) | 403・503 `credential_unavailable` |
-| `POST /accounts/{a}/repositories/{r}/issue-candidates` | 席を持つ人 | `{exclude?: [番号]}`(選び直しで外す番号) | 200 `{source: "ranking" \| "recent", milestone, candidates: [{number, title, selected, reason}], note}` | 400(ID の形)・403・404 `repository_not_found` |
-| `POST /accounts/{a}/sprint-runs` | 席を持つ人 | `{repository_id, issues}`(1〜20 件の重なりの無い番号) | 201 `{sprint_run_id, state: "starting"}` | 400・403・404・409・503(下の表) |
-| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | — | 200 `{sprint_runs: [{sprint_run_id, repository_id, issues, state, failure_reason, created_at, repo, current_phase, current_step, last_event_at}]}`(新しい順 50 件。後ろの 4 項目は進捗の事象から導く。規則は [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | 400(ID の形)・403・503 `progress_unavailable` |
-| `POST /accounts/{a}/sprint-runs/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished`・`release_after_main` |
+| `GET /accounts/{a}/github/install-url` | 席を持つ人 | — | `[{url}]`(`https://github.com/apps//installations/new`) | 403 |
+| `GET /accounts/{a}/github/authorize-url` | 持ち主 | — | `[{url}]`(`https://github.com/login/oauth/authorize?client_id=&state=`) | 403 |
+| `POST /accounts/{a}/github/candidates` | 持ち主 | `{code, state}`(認可の戻り。要素は 1 個) | `{proof, candidates: [{repository_id, full_name, installation_id, registered}]}` | 400 `invalid_request`(`state` が合わない・`code` が無いか換えられない)・403 |
+| `GET /accounts/{a}/repositories` | 席を持つ人 | — | `[{repository_id, full_name, installation_id, registered_at, active_sprint_runs, max_concurrent_runs}]` | 403 |
+| `POST /accounts/{a}/repositories` | 持ち主 | `{proof, repository_id}` | 登録の 1 行(`active_sprint_runs` を除く) | 400(証明の署名・期限・利用者・アカウントの違い)・403(証明に無い ID、登録の直前に導入の token を作れない。I33)・409 `repository_exists`(I32) |
+| `DELETE /accounts/{a}/repositories?repository_id=` | 持ち主 | 鍵 `repository_id` | `{removed: true}` | 400(ID の形)・403・404 `repository_not_found`・409 `repository_in_use`(I36) |
+| `GET /accounts/{a}/model-credential` | 持ち主 | — | `[{registered, kind, updated_at, external_id}]`。未登録なら `kind`・`updated_at` は `null`。`external_id` は常に返す(秘密でない) | 403 |
+| `PUT /accounts/{a}/model-credential` | 持ち主 | `{kind: "anthropic_api_key", api_key}`(1〜512 字)か `{kind: "aws_role", role_arn, region}`(`^arn:aws[a-z-]*:iam::\d{12}:role/.+$` で 2,048 字まで、`^[a-z]{2}(-[a-z]+)+-\d$` で 32 字まで)。要素は 1 個 | 取得と同じ形(値も ARN も返さない) | 400・403・503 `credential_unavailable` |
+| `DELETE /accounts/{a}/model-credential` | 持ち主 | 鍵なし(要素は 1 個) | `{removed: true}`(無くても成功) | 403・503 `credential_unavailable` |
+| `POST /accounts/{a}/issue-candidates` | 席を持つ人 | `{repository_id, exclude?: [番号]}`(選び直しで外す番号) | `{source: "ranking" \| "recent", milestone, candidates: [{number, title, selected, reason}], note}` | 400(ID の形)・403・404 `repository_not_found` |
+| `POST /accounts/{a}/sprint-runs` | 席を持つ人 | `{repository_id, issues, idempotency_key}`(`issues` は 1〜20 件の重なりの無い番号) | `{sprint_run_id, state: "starting"}` | 400・403・404・409・503(下の表) |
+| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | 条件 `repository_id`(省くとアカウント全体) | `[{sprint_run_id, repository_id, issues, state, failure_reason, created_at, repo, current_phase, current_step, last_event_at}]`(新しい順 50 件。後ろの 4 項目は進捗の事象から導く。規則は [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | 要求全体の 400(ID の形)・403・503 `progress_unavailable` |
+| `POST /accounts/{a}/sprint-runs/cancel` | 席を持つ人 | `{sprint_run_id}` | `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished`・`release_after_main` |
**持ち主だけの経路は、席を持つだけの利用者に他人のアカウントと同じ 403 `forbidden` を返す**(`requireOwner`)。チームのアカウントでは、リポジトリの登録と外し・認可・候補・キーの 3 つの操作は持ち主(`role` が `owner`)だけが、スプリントの開始・一覧・取り消しと課題の候補は席を持つ人が行う。
@@ -430,7 +430,7 @@ Workflows・Administration・Projects は持たない。Workflows は CI を書
| 観点 | 縛る振る舞い |
| --- | --- |
| 候補と登録(I32・I33) | 偽の GitHub が返した導入の対象のうち、利用者の届くものだけが候補に出ること。証明に無い ID・届かない導入の登録が 403 で行が増えないこと。同じ ID の 2 回目と同時の 2 本が 409 で 1 行であること |
-| 外し(I36) | 終わりの状態でない実行があると 409、終われば 204 であること |
+| 外し(I36) | 終わりの状態でない実行があると要素の 409、終われば成功の要素(`{removed: true}`)であること |
| 他人のアカウント | 他人のアカウントへの経路が #2170 と同じ 403 の本文を返し、持ち主でない席の人の持ち主だけの操作も同じ 403 であること |
| キーの持ち込み(I38) | 2 種類の保存・差し替え・消去が通り、応答が 4 項目だけであること。テストのキーの値が、応答・ログ・事象・`RunTask` の環境変数のどこにも無く、ARN は自分のアカウントの `byok` のジョブにだけ載ること |
| 外部 ID | 外部 ID がアカウントごとに違い、`AssumeRole` に付くこと。偽の STS が断ると `failed` になること |
diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md
index 05d31d582..ec8e2fc6c 100644
--- a/docs/specifications/ndf-factory-service-boundary.md
+++ b/docs/specifications/ndf-factory-service-boundary.md
@@ -443,6 +443,22 @@ stateDiagram-v2
`factory/api/src/index.ts` は `requestResume`・`claimLocalRunJob`・`reportLocalRunJobEnded`・`cancelSprintRun` を公開する。
+### 利用者向けの操作の形
+
+画面・委任を受けたエージェント・MCP のクライアントが呼ぶ利用者向けの操作(契約の操作に `x-ndf-access` を持つもの)は、どれも次の形で受け渡す。形の正は `factory/contracts/openapi/*.yaml` の拡張(`x-ndf-access`・`x-ndf-keys`・`x-ndf-id`)と、そこから生成する操作の表 `factory/contracts/src/generated/operations.ts`(`npm run generate -w contracts -- --check` で食い違いを落とす)である。ランナー向けの経路(`/run-jobs/*`)と進捗の受け口(`/ingest/*`)はこの形に入らない。
+
+| 種類 | メソッド | 入力 | 成功 |
+| --- | --- | --- | --- |
+| 一覧 | `GET` | 条件の query | 200 と値の配列(要素の結果で包まない) |
+| 指定の取得・削除 | `GET`・`DELETE` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`)。鍵を持たない操作は query 無しで要素 1 個 | 200 と要素の結果の配列 |
+| 作成・更新・操作 | `POST`・`PATCH`・`PUT` | 本文の要素の配列(1 件でも要素 1 個の配列) | 200 と要素の結果の配列 |
+
+- **経路の変数はアカウントの ID だけ**である。実行・ループ・リポジトリなどの識別子は鍵の query か要素の項目で渡し、要素ごとにそのアカウントに属するかを照らす
+- **要素の結果**は入力と同じ順・同じ数で、成功は `{ok: true, value}`、失敗は `{ok: false, status, error: {code, message, details?}}` である。失敗の `status` と `code` は、各操作の確定仕様の表にある誤りのまま(400・403・404・409・422・503)。要素がすべて失敗しても応答は 200 である
+- **要求全体を断るのは次だけ**である。本文が配列でない(400 `invalid_request`)・要素が 0 個(400 `empty_items`)・101 個以上(400 `too_many_items`)・同じ鍵の要素が 2 つ(400 `duplicate_key`)・資格情報が無効(401)・経路のアカウントを解決できない(403 `forbidden`)・委任の資格情報で人だけが確定できる操作を呼んだ(403 `human_only`)・読み取りだけの委任で書き込みの操作を呼んだ(403 `scope_insufficient`)・操作の記録を追記できない(503 `audit_unavailable`)
+- **新しい識別子を作る作成**(スプリントの実行・開発ループ・チーム・委任の資格情報)は、要素ごとに冪等の鍵 `idempotency_key`(1〜64 字の英数字とハイフン)を受ける。同じ利用者・同じ操作・同じ鍵の 24 時間以内の 2 回目は、1 回目に作ったものを返す
+- 画面の API クライアント(`factory/web/src/api.ts`)は要求を操作の表から組み、要素の結果をほどいて 1 件の値か `ApiFailure` を返す
+
### 常に成り立つ条件(サービス全体)
| # | 集約 | 条件 | 破れたときの扱い | 縛る課題 |
diff --git a/docs/specifications/ndf-factory-sprint-views.md b/docs/specifications/ndf-factory-sprint-views.md
index 3de09dbc1..c97be9abe 100644
--- a/docs/specifications/ndf-factory-sprint-views.md
+++ b/docs/specifications/ndf-factory-sprint-views.md
@@ -105,14 +105,16 @@ graph TD
### 経路(`factory/contracts/openapi/runs.yaml`)
-| 経路 | 応答 |
+入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は一覧の 1 件か、成功の要素の `value` である。
+
+| 経路 | 値 / 失敗 |
| --- | --- |
-| `GET /accounts/{accountId}/sprint-runs` | 200 `SprintRunList`。各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない / 403 |
-| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}` | 200 `SprintRunDetail` / 403 / 503 |
-| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}` | 200 `StepDetail`。`stepKey` は事象の `idempotency_key` を URL で符号化したもの / 403 / 503 |
-| `GET /accounts/{accountId}/repositories/{repositoryId}/backlog` | 200 `Backlog` / 403 / 503(保存か GitHub の口の失敗) |
+| `GET /accounts/{accountId}/sprint-runs` | 一覧。`SprintRunRow` の配列。各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない / 要求全体の 403 |
+| `GET /accounts/{accountId}/sprint-runs/detail?sprint_run_id=` | 指定の取得。`SprintRunDetail` / 要素の 403 / 要素の 503 |
+| `GET /accounts/{accountId}/sprint-runs/steps?sprint_run_id=&step_key=` | 指定の取得。実行の ID は 1 つの条件で、鍵は工程の `step_key`(事象の `idempotency_key`)を繰り返す。`StepDetail` / 他人の実行は要求全体の 403、要素の 403 / 503 |
+| `GET /accounts/{accountId}/backlogs?repository_id=` | 指定の取得。`Backlog` / 要素の 403 / 要素の 503(保存か GitHub の口の失敗) |
-403 はどれも `{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}` である。無い実行と他人の実行で、同じ回数の読み出しの後に返す。登録の無いリポジトリも 404 にしない。404 を返すと、アカウントがそのリポジトリを取り込んでいるかが分かるためである。保存の失敗は 503 で、応答に中身を出さない。
+403 はどれも `{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}`(要素の結果では `{ok: false, status: 403, error}`)である。無い実行と他人の実行で、同じ回数の読み出しの後に返す。登録の無いリポジトリも 404 にしない。404 を返すと、アカウントがそのリポジトリを取り込んでいるかが分かるためである。保存の失敗は 503 で、応答に中身を出さない。
`SprintRunDetail`:
diff --git a/docs/specifications/ndf-factory-ui-components.md b/docs/specifications/ndf-factory-ui-components.md
index 02c189884..8d2d654bf 100644
--- a/docs/specifications/ndf-factory-ui-components.md
+++ b/docs/specifications/ndf-factory-ui-components.md
@@ -88,9 +88,9 @@ Neural Development Factory の Web の画面を組む部品の一覧と使い分
| 操作 | 入力 | 出力 | 失敗の形 |
| --- | --- | --- | --- |
-| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunSummary[]`。見つからない ID の要素は返さない | `ApiFailure`(401・403・5xx) |
+| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunDetail[]`(入力と同じ順) | `ApiFailure`(401・要求全体か最初に失敗した要素の 403・5xx。無い実行も 403) |
-`sprintRunDetails` の本物の実装は、詳細の経路がまだ無いため `GET /accounts/{a}/sprint-runs` の新しい順 50 件から ID で選ぶ。それより古い実行は見つからない扱いになる。詳細の経路ができたら本物の実装だけを差し替え、画面は変えない。
+`sprintRunDetails` の本物の実装は、実行の ID を鍵の query に繰り返して `GET /accounts/{a}/sprint-runs/detail` を 1 回呼び、要素の結果をほどく([利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形))。
## つながりの状態
From 0091b3a4c62152a3a8e7f4ca9e6eb8ee5aefc9f9 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 23:23:33 +0000
Subject: [PATCH 08/16] =?UTF-8?q?=E5=88=A9=E7=94=A8=E8=80=85=E5=90=91?=
=?UTF-8?q?=E3=81=91=E3=81=AE=E6=93=8D=E4=BD=9C=E3=81=AE=E5=BD=A2=E3=82=92?=
=?UTF-8?q?=E5=A5=91=E7=B4=84=E3=81=AE=E5=AE=9A=E7=BE=A9=E3=81=AE=E7=A2=BA?=
=?UTF-8?q?=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=B8=E7=A7=BB=E3=81=97=E3=80=81?=
=?UTF-8?q?=E5=85=A8=E4=BD=93=E3=81=AE=E5=A5=91=E7=B4=84=E3=82=92=E8=A1=8C?=
=?UTF-8?q?=E6=95=B0=E3=81=AE=E4=B8=8A=E9=99=90=E3=81=AB=E5=8F=8E=E3=82=81?=
=?UTF-8?q?=E3=82=8B=EF=BC=88#2384=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ndf-factory-service-boundary.md が 516 行で上限 500 行を超え、doc-line-limit-check と pytest が落ちていた。
節は factory/contracts の操作の表を正とする契約の形のため、契約の定義と型の確定仕様へ移し、参照 6 件の行き先を合わせた。
Co-Authored-By: Claude Opus 5.5
---
.../ndf-factory-accounts-and-plans.md | 2 +-
.../ndf-factory-contracts-and-progress.md | 16 ++++++++++++++++
...ndf-factory-dev-loops-data-and-contracts.md | 2 +-
...ndf-factory-organizer-data-and-contracts.md | 2 +-
.../ndf-factory-runs-and-runner.md | 2 +-
.../ndf-factory-service-boundary.md | 18 +-----------------
.../specifications/ndf-factory-sprint-views.md | 2 +-
.../ndf-factory-ui-components.md | 2 +-
8 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/docs/specifications/ndf-factory-accounts-and-plans.md b/docs/specifications/ndf-factory-accounts-and-plans.md
index 055d62680..4408d71d2 100644
--- a/docs/specifications/ndf-factory-accounts-and-plans.md
+++ b/docs/specifications/ndf-factory-accounts-and-plans.md
@@ -214,7 +214,7 @@ sequenceDiagram
### API の経路
-経路はすべて `Authorization: Bearer `(または委任の資格情報)を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である(401 は要求全体)。チームの作成と席の追加・変更・削除は人だけが確定でき、委任の資格情報では要求全体を 403 `human_only` で断る。
+経路はすべて `Authorization: Bearer `(または委任の資格情報)を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である(401 は要求全体)。チームの作成と席の追加・変更・削除は人だけが確定でき、委任の資格情報では要求全体を 403 `human_only` で断る。
| 経路 | 要素・条件 | 値 | 失敗 |
| --- | --- | --- | --- |
diff --git a/docs/specifications/ndf-factory-contracts-and-progress.md b/docs/specifications/ndf-factory-contracts-and-progress.md
index 7f31250ad..d2ad50a79 100644
--- a/docs/specifications/ndf-factory-contracts-and-progress.md
+++ b/docs/specifications/ndf-factory-contracts-and-progress.md
@@ -362,6 +362,22 @@ sequenceDiagram
- **`SavedAnswerReader` は `(approvalRequestId) => Promise` である。** `SavedAnswer` は `{ approvalRequestId; sprintRunId; kind; decision?; answeredByAccountId }` で、`decision` は判断(`kind` が `decision`)では持たない。`null` は未保存、読めないときは投げる(受け口が 503 にする)
- **handler は `savedAnswers` に `saved-answers.ts` の `savedAnswersFromEnv()` を渡す。** 今は常に `SavedAnswersUnavailableError` を投げる既定で、`api` の経路は 503 を返す。#2173 が同じファイルの中身を本物の読み出しに替え、受け口のほかのファイルは変えずに済む
+### 利用者向けの操作の形
+
+画面・委任を受けたエージェント・MCP のクライアントが呼ぶ利用者向けの操作(契約の操作に `x-ndf-access` を持つもの)は、どれも次の形で受け渡す。形の正は `factory/contracts/openapi/*.yaml` の拡張(`x-ndf-access`・`x-ndf-keys`・`x-ndf-id`)と、そこから生成する操作の表 `factory/contracts/src/generated/operations.ts`(`npm run generate -w contracts -- --check` で食い違いを落とす)である。ランナー向けの経路(`/run-jobs/*`)と進捗の受け口(`/ingest/*`)はこの形に入らない。
+
+| 種類 | メソッド | 入力 | 成功 |
+| --- | --- | --- | --- |
+| 一覧 | `GET` | 条件の query | 200 と値の配列(要素の結果で包まない) |
+| 指定の取得・削除 | `GET`・`DELETE` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`)。鍵を持たない操作は query 無しで要素 1 個 | 200 と要素の結果の配列 |
+| 作成・更新・操作 | `POST`・`PATCH`・`PUT` | 本文の要素の配列(1 件でも要素 1 個の配列) | 200 と要素の結果の配列 |
+
+- **経路の変数はアカウントの ID だけ**である。実行・ループ・リポジトリなどの識別子は鍵の query か要素の項目で渡し、要素ごとにそのアカウントに属するかを照らす
+- **要素の結果**は入力と同じ順・同じ数で、成功は `{ok: true, value}`、失敗は `{ok: false, status, error: {code, message, details?}}` である。失敗の `status` と `code` は、各操作の確定仕様の表にある誤りのまま(400・403・404・409・422・503)。要素がすべて失敗しても応答は 200 である
+- **要求全体を断るのは次だけ**である。本文が配列でない(400 `invalid_request`)・要素が 0 個(400 `empty_items`)・101 個以上(400 `too_many_items`)・同じ鍵の要素が 2 つ(400 `duplicate_key`)・資格情報が無効(401)・経路のアカウントを解決できない(403 `forbidden`)・委任の資格情報で人だけが確定できる操作を呼んだ(403 `human_only`)・読み取りだけの委任で書き込みの操作を呼んだ(403 `scope_insufficient`)・操作の記録を追記できない(503 `audit_unavailable`)
+- **新しい識別子を作る作成**(スプリントの実行・開発ループ・チーム・委任の資格情報)は、要素ごとに冪等の鍵 `idempotency_key`(1〜64 字の英数字とハイフン)を受ける。同じ利用者・同じ操作・同じ鍵の 24 時間以内の 2 回目は、1 回目に作ったものを返す
+- 画面の API クライアント(`factory/web/src/api.ts`)は要求を操作の表から組み、要素の結果をほどいて 1 件の値か `ApiFailure` を返す
+
### 常に成り立つ条件
I10 は全体の契約のもの。#2170 の I24 に続けて番号を振る。I37 は #2186 が #2216 の照合として足し、I44・I45・I50 は #2216 が足した(I32〜I36・I38〜I43 は #2186 のほかの集約の条件、I46〜I49 は [起票の依頼と起票の代行](ndf-factory-issue-requests.md) の台帳の条件)。
diff --git a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md
index 70dc77a8e..d153414b6 100644
--- a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md
+++ b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md
@@ -150,7 +150,7 @@ erDiagram
### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`)
-どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。ループの要素はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found` の要素の結果になる。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。
+どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。ループの要素はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found` の要素の結果になる。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。
| 経路 | 誰が | 要素・条件 | 値 | 失敗 |
| --- | --- | --- | --- | --- |
diff --git a/docs/specifications/ndf-factory-organizer-data-and-contracts.md b/docs/specifications/ndf-factory-organizer-data-and-contracts.md
index 44af6d06b..7677be2ca 100644
--- a/docs/specifications/ndf-factory-organizer-data-and-contracts.md
+++ b/docs/specifications/ndf-factory-organizer-data-and-contracts.md
@@ -101,7 +101,7 @@ erDiagram
### 画面向けの経路(`factory/contracts/openapi/organizer.yaml`)
-入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗の形」は要素の結果の状態である(401 は要求全体)。
+入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗の形」は要素の結果の状態である(401 は要求全体)。
| 経路 | 要素・条件 | 値 | 失敗の形 |
| --- | --- | --- | --- |
diff --git a/docs/specifications/ndf-factory-runs-and-runner.md b/docs/specifications/ndf-factory-runs-and-runner.md
index a75598c46..47eb7c853 100644
--- a/docs/specifications/ndf-factory-runs-and-runner.md
+++ b/docs/specifications/ndf-factory-runs-and-runner.md
@@ -149,7 +149,7 @@ graph TD
### 画面向けの経路
-機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力・成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。下の表の「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。
+機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力・成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。下の表の「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。
| 経路 | 誰が | 要素・条件 | 値 | 失敗 |
| --- | --- | --- | --- | --- |
diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md
index ec8e2fc6c..f70b1c18e 100644
--- a/docs/specifications/ndf-factory-service-boundary.md
+++ b/docs/specifications/ndf-factory-service-boundary.md
@@ -443,22 +443,6 @@ stateDiagram-v2
`factory/api/src/index.ts` は `requestResume`・`claimLocalRunJob`・`reportLocalRunJobEnded`・`cancelSprintRun` を公開する。
-### 利用者向けの操作の形
-
-画面・委任を受けたエージェント・MCP のクライアントが呼ぶ利用者向けの操作(契約の操作に `x-ndf-access` を持つもの)は、どれも次の形で受け渡す。形の正は `factory/contracts/openapi/*.yaml` の拡張(`x-ndf-access`・`x-ndf-keys`・`x-ndf-id`)と、そこから生成する操作の表 `factory/contracts/src/generated/operations.ts`(`npm run generate -w contracts -- --check` で食い違いを落とす)である。ランナー向けの経路(`/run-jobs/*`)と進捗の受け口(`/ingest/*`)はこの形に入らない。
-
-| 種類 | メソッド | 入力 | 成功 |
-| --- | --- | --- | --- |
-| 一覧 | `GET` | 条件の query | 200 と値の配列(要素の結果で包まない) |
-| 指定の取得・削除 | `GET`・`DELETE` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`)。鍵を持たない操作は query 無しで要素 1 個 | 200 と要素の結果の配列 |
-| 作成・更新・操作 | `POST`・`PATCH`・`PUT` | 本文の要素の配列(1 件でも要素 1 個の配列) | 200 と要素の結果の配列 |
-
-- **経路の変数はアカウントの ID だけ**である。実行・ループ・リポジトリなどの識別子は鍵の query か要素の項目で渡し、要素ごとにそのアカウントに属するかを照らす
-- **要素の結果**は入力と同じ順・同じ数で、成功は `{ok: true, value}`、失敗は `{ok: false, status, error: {code, message, details?}}` である。失敗の `status` と `code` は、各操作の確定仕様の表にある誤りのまま(400・403・404・409・422・503)。要素がすべて失敗しても応答は 200 である
-- **要求全体を断るのは次だけ**である。本文が配列でない(400 `invalid_request`)・要素が 0 個(400 `empty_items`)・101 個以上(400 `too_many_items`)・同じ鍵の要素が 2 つ(400 `duplicate_key`)・資格情報が無効(401)・経路のアカウントを解決できない(403 `forbidden`)・委任の資格情報で人だけが確定できる操作を呼んだ(403 `human_only`)・読み取りだけの委任で書き込みの操作を呼んだ(403 `scope_insufficient`)・操作の記録を追記できない(503 `audit_unavailable`)
-- **新しい識別子を作る作成**(スプリントの実行・開発ループ・チーム・委任の資格情報)は、要素ごとに冪等の鍵 `idempotency_key`(1〜64 字の英数字とハイフン)を受ける。同じ利用者・同じ操作・同じ鍵の 24 時間以内の 2 回目は、1 回目に作ったものを返す
-- 画面の API クライアント(`factory/web/src/api.ts`)は要求を操作の表から組み、要素の結果をほどいて 1 件の値か `ApiFailure` を返す
-
### 常に成り立つ条件(サービス全体)
| # | 集約 | 条件 | 破れたときの扱い | 縛る課題 |
@@ -510,7 +494,7 @@ stateDiagram-v2
- 設計 Pull Request: [#2175](https://github.com/devbasex/ai-plugins/pull/2175)、実装: [#2177](https://github.com/devbasex/ai-plugins/pull/2177)
- サービスのディレクトリ: [factory/README.md](../../factory/README.md)
- 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md)
-- 契約の定義と型・進捗の受け口・実行ジョブの token(#2171): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md)
+- 契約の定義と型・進捗の受け口・実行ジョブの token(#2171)、[利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形)(#2384): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md)
- 境界の検査: [scripts/check-service-boundary.py](../../scripts/check-service-boundary.py)、許可の一覧: [scripts/service-boundary-allow.json](../../scripts/service-boundary-allow.json)
- 追加待ちの扱い: [必須のチェックの突き合わせ](ci-required-checks-reconcile.md)
- 版と配布の規則(ランナーが導入する NDF に当たる): [版と配布の正本](../versioning-and-distribution.md)
diff --git a/docs/specifications/ndf-factory-sprint-views.md b/docs/specifications/ndf-factory-sprint-views.md
index c97be9abe..72d888115 100644
--- a/docs/specifications/ndf-factory-sprint-views.md
+++ b/docs/specifications/ndf-factory-sprint-views.md
@@ -105,7 +105,7 @@ graph TD
### 経路(`factory/contracts/openapi/runs.yaml`)
-入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は一覧の 1 件か、成功の要素の `value` である。
+入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「値」は一覧の 1 件か、成功の要素の `value` である。
| 経路 | 値 / 失敗 |
| --- | --- |
diff --git a/docs/specifications/ndf-factory-ui-components.md b/docs/specifications/ndf-factory-ui-components.md
index 8d2d654bf..6e3eca86d 100644
--- a/docs/specifications/ndf-factory-ui-components.md
+++ b/docs/specifications/ndf-factory-ui-components.md
@@ -90,7 +90,7 @@ Neural Development Factory の Web の画面を組む部品の一覧と使い分
| --- | --- | --- | --- |
| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunDetail[]`(入力と同じ順) | `ApiFailure`(401・要求全体か最初に失敗した要素の 403・5xx。無い実行も 403) |
-`sprintRunDetails` の本物の実装は、実行の ID を鍵の query に繰り返して `GET /accounts/{a}/sprint-runs/detail` を 1 回呼び、要素の結果をほどく([利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形))。
+`sprintRunDetails` の本物の実装は、実行の ID を鍵の query に繰り返して `GET /accounts/{a}/sprint-runs/detail` を 1 回呼び、要素の結果をほどく([利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形))。
## つながりの状態
From ff65626257d7c79c8d5f0ef514779ff8373470fe Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 23:40:31 +0000
Subject: [PATCH 09/16] =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?=
=?UTF-8?q?=E3=81=AE=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E8=AA=8D=E5=8F=AF?=
=?UTF-8?q?=E3=82=92=E3=80=81=E8=A6=81=E7=B4=A0=E3=81=94=E3=81=A8=E3=81=AB?=
=?UTF-8?q?=E7=85=A7=E3=82=89=E3=81=99=E7=B5=90=E6=9E=9C=E3=81=AE=E5=BD=A2?=
=?UTF-8?q?=E3=81=B8=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B=EF=BC=88#2384?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/specifications/ndf-factory-dev-loops.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/specifications/ndf-factory-dev-loops.md b/docs/specifications/ndf-factory-dev-loops.md
index aee611953..7c889c122 100644
--- a/docs/specifications/ndf-factory-dev-loops.md
+++ b/docs/specifications/ndf-factory-dev-loops.md
@@ -370,7 +370,7 @@ sequenceDiagram
## セキュリティ
- サービスは `local` のアカウントと認証を知らない。利用上限の事象はリセット時刻だけを持つ
-- ループの操作は、そのリポジトリの登録を持つアカウントだけが行える。ループの経路は `resolveFromRequest` の後にループの `accountId` と登録を照らし、他人は 403、無ければ 404 になる
+- ループの操作は、そのリポジトリの登録を持つアカウントだけが行える。ループの経路は `resolveFromRequest` の後に要素ごとにループの `accountId` と登録を照らし、他人のループの要素は 403、無ければ 404 の要素の結果になる
- 同時の実行の数の宣言は持ち主だけが変えられる
## テスト観点
From 1dfad18a0fe3a8b445bb4f3b959cd5b43c9298e2 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Wed, 4 Nov 2026 23:55:19 +0000
Subject: [PATCH 10/16] =?UTF-8?q?docs:=20=E9=96=A2=E9=80=A3=E3=83=AA?=
=?UTF-8?q?=E3=83=B3=E3=82=AF=E3=81=AE=E8=A1=8C=E3=81=8B=E3=82=89=E8=AA=B2?=
=?UTF-8?q?=E9=A1=8C=E7=95=AA=E5=8F=B7=E3=81=AE=E7=94=B1=E6=9D=A5=E3=82=92?=
=?UTF-8?q?=E5=A4=96=E3=81=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
docs/specifications/ndf-factory-service-boundary.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md
index f70b1c18e..d8db81a5a 100644
--- a/docs/specifications/ndf-factory-service-boundary.md
+++ b/docs/specifications/ndf-factory-service-boundary.md
@@ -494,7 +494,7 @@ stateDiagram-v2
- 設計 Pull Request: [#2175](https://github.com/devbasex/ai-plugins/pull/2175)、実装: [#2177](https://github.com/devbasex/ai-plugins/pull/2177)
- サービスのディレクトリ: [factory/README.md](../../factory/README.md)
- 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md)
-- 契約の定義と型・進捗の受け口・実行ジョブの token(#2171)、[利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形)(#2384): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md)
+- 契約の定義と型・進捗の受け口・実行ジョブの token・[利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md)
- 境界の検査: [scripts/check-service-boundary.py](../../scripts/check-service-boundary.py)、許可の一覧: [scripts/service-boundary-allow.json](../../scripts/service-boundary-allow.json)
- 追加待ちの扱い: [必須のチェックの突き合わせ](ci-required-checks-reconcile.md)
- 版と配布の規則(ランナーが導入する NDF に当たる): [版と配布の正本](../versioning-and-distribution.md)
From d5dfbfe4b3508a0e0b5ae0db2fb3642745ad0c93 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Thu, 5 Nov 2026 00:09:41 +0000
Subject: [PATCH 11/16] =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=81=AE=E5=9C=B0?=
=?UTF-8?q?=E5=9B=B3=E3=81=AE=E4=BA=88=E5=AE=9A=E3=81=AE=E7=94=BB=E9=9D=A2?=
=?UTF-8?q?=E3=81=AB=E3=80=81=E3=81=9D=E3=81=AE=E7=94=BB=E9=9D=A2=E3=82=92?=
=?UTF-8?q?=E4=BD=9C=E3=82=8B=E8=AA=B2=E9=A1=8C=E3=82=92=E6=9B=B8=E3=81=8F?=
=?UTF-8?q?=EF=BC=88#2431=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
予定の 3 行(承認・請求と使用量・手元のマシン)の載る機能の末尾に、作る課題の番号と題を括弧で添える。冒頭の手順に、作る課題がその行を「あり」へ変えるときの扱いを足す。
Co-Authored-By: Claude Opus 5.5
---
docs/specifications/ndf-factory-screen-map.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md
index 0a26ac8ae..6c5990a20 100644
--- a/docs/specifications/ndf-factory-screen-map.md
+++ b/docs/specifications/ndf-factory-screen-map.md
@@ -1,6 +1,6 @@
# Neural Development Factory: 画面の地図
-Neural Development Factory の Web の画面の全体を 1 つの表にまとめる。どの経路にどの画面があり、どの役割が使い、確定仕様のどの機能がどの画面に載るかを示す。画面を足す課題は、この表に 1 行を足し、`factory/web/src/screens.ts` に同じ行を足し、翻訳のファイルにキーを足し、[画面の部品](ndf-factory-ui-components.md)で画面を組む。
+Neural Development Factory の Web の画面の全体を 1 つの表にまとめる。どの経路にどの画面があり、どの役割が使い、確定仕様のどの機能がどの画面に載るかを示す。画面を足す課題は、この表に 1 行を足し、`factory/web/src/screens.ts` に同じ行を足し、翻訳のファイルにキーを足し、[画面の部品](ndf-factory-ui-components.md)で画面を組む。予定の行に作る課題として書かれた課題は、行を足さずにその行の状態を「あり」へ変え、経路を書き、作る課題の括弧を確定仕様の節へ置き換える(節が無ければ括弧を消す)。
## 概要
@@ -41,9 +41,9 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ
| `/accounts/:accountId/loops/:loopId` | ループの詳細 | 個人・管理者・席 | あり | 周回・周回の実行と区間の数・利用上限の再開の時刻・人の判断待ちと答え・予算の変更・止める・次の周回を始める |
| `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | あり | 順位の表と棚卸しの記録(読むだけ) |
| `/delegations` | エージェントへの委任 | 個人・管理者・席 | あり | 委任の資格情報の発行(値は発行の直後に 1 度だけ出す)・一覧(範囲・期限・状態)・取り消し。オフラインの間は発行と取り消しをしない。自分のアカウントからたどる |
-| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 |
-| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 |
-| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 |
+| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知(作る課題: #2173 承認ゲートの通知(Slack を含む)と Web からの承認) |
+| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済(作る課題: #2174 サブスクリプションと Bedrock の token 課金) |
+| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍(作る課題: #2189 手元のマシン(利用者の環境で、利用者の claude ログインで実行する)) |
## ナビゲーション
From 8203279085021b737007aec6ce0dcbaf686eed22 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Thu, 5 Nov 2026 00:34:38 +0000
Subject: [PATCH 12/16] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration=20?=
=?UTF-8?q?=E2=80=94=20factory/api/src/audit/middleware.ts#recordOf?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
委任の資格情報の頭 ndfd_ を delegations/model.ts の CREDENTIAL_PREFIX に寄せる。
auth/resolve.ts の DELEGATION_PREFIX をそこから組み、判定を isDelegationAuthorization として公開し、
認証の分岐(E2)と操作の記録の via の判定(I16)が同じ関数を使う。
delegations/service.ts の値の正規表現も CREDENTIAL_PREFIX から組む。振る舞いは変えない。
Item-Id: I-001
Impl-Runtime: claude
Impl-Model: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5
---
factory/api/src/audit/middleware.ts | 4 ++--
factory/api/src/auth/resolve.ts | 12 +++++++++---
factory/api/src/delegations/service.ts | 2 +-
3 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/factory/api/src/audit/middleware.ts b/factory/api/src/audit/middleware.ts
index ab9e478ff..570647e8b 100644
--- a/factory/api/src/audit/middleware.ts
+++ b/factory/api/src/audit/middleware.ts
@@ -3,7 +3,7 @@
import { randomUUID } from "node:crypto";
import type { Context, MiddlewareHandler } from "hono";
import { OPERATIONS, findOperation, type Operation } from "@ndf-factory/contracts/operations";
-import { isMcpInternal, type AuthEnv } from "../auth/resolve.ts";
+import { isDelegationAuthorization, isMcpInternal, type AuthEnv } from "../auth/resolve.ts";
import { ServiceUnavailableError } from "../errors.ts";
import { toItem, type AuditLog, type AuditOutcome, type AuditRecord } from "./log.ts";
@@ -36,7 +36,7 @@ async function recordOf(c: Context, at: string): Promise {
const caller = c.get("caller");
const trace = c.get("operation");
// 呼び出し元の種類は資格情報の種類と内部の印だけで決め、外から届くほかのヘッダーを見ない(I16)
- const delegated = (c.req.header("Authorization") ?? "").startsWith("Bearer ndfd_");
+ const delegated = isDelegationAuthorization(c.req.header("Authorization"));
const via = caller?.via ?? (isMcpInternal(c.env) ? "mcp" : delegated ? "api" : "screen");
const matched = trace ? { op: findOperation(trace.operationId), accountId: trace.accountId } : match(c.req.method, c.req.path);
const status = c.res.status;
diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts
index 3d19932cf..2dd8eb5c2 100644
--- a/factory/api/src/auth/resolve.ts
+++ b/factory/api/src/auth/resolve.ts
@@ -3,6 +3,7 @@ import type { AccountService } from "../accounts/service.ts";
import type { AccountContext, Caller } from "../accounts/model.ts";
import type { AuditLog } from "../audit/log.ts";
import type { DelegationService } from "../delegations/service.ts";
+import { CREDENTIAL_PREFIX } from "../delegations/model.ts";
import { AuthError } from "../errors.ts";
import { verifyAuthorization, type AccessTokenVerifier } from "./verify.ts";
@@ -22,12 +23,17 @@ export function isMcpInternal(env: unknown): boolean {
return typeof env === "object" && env !== null && (env as Record)[MCP_INTERNAL] === true;
}
-const DELEGATION_PREFIX = "Bearer ndfd_";
+const DELEGATION_PREFIX = `Bearer ${CREDENTIAL_PREFIX}`;
+
+// Authorization の値が委任の資格情報か(頭で決める)。認証の分岐(E2)と記録の via(I16)が同じ判定を使う
+export function isDelegationAuthorization(header: string | undefined): boolean {
+ return header?.startsWith(DELEGATION_PREFIX) ?? false;
+}
// Authorization の値を、委任の資格情報(頭が ndfd_)か Cognito の access token として検証する(E2)。どの失敗も 401
export async function verifyCaller(deps: AuthDeps, authorization: string | undefined, mcp: boolean): Promise {
- if (authorization?.startsWith(DELEGATION_PREFIX)) {
- const verified = deps.delegations ? await deps.delegations.verify(authorization.slice("Bearer ".length)) : null;
+ if (isDelegationAuthorization(authorization)) {
+ const verified = deps.delegations ? await deps.delegations.verify(authorization!.slice("Bearer ".length)) : null;
if (!verified) throw new AuthError();
return {
userId: verified.userId,
diff --git a/factory/api/src/delegations/service.ts b/factory/api/src/delegations/service.ts
index 7d38239c6..a2844788a 100644
--- a/factory/api/src/delegations/service.ts
+++ b/factory/api/src/delegations/service.ts
@@ -36,7 +36,7 @@ function base32(bytes: Buffer): string {
return out;
}
-const VALUE = /^ndfd_([a-z2-7]{26})_([A-Za-z0-9_-]{43})$/;
+const VALUE = new RegExp(`^${CREDENTIAL_PREFIX}([a-z2-7]{26})_([A-Za-z0-9_-]{43})$`);
const sha256 = (secret: string) => createHash("sha256").update(secret, "utf8").digest("hex");
// 照合が通った委任の資格情報。人の直の操作と同じ Caller を組むための値と、範囲を持つ
From 510ee2291b30237bc21bb59a61df60c4ac4b87ea Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Thu, 5 Nov 2026 00:35:06 +0000
Subject: [PATCH 13/16] =?UTF-8?q?Refactor:=20fix=5Fdependency=5Fdirection?=
=?UTF-8?q?=20=E2=80=94=20factory/api/src/routes/factory.ts#positiveInt?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
factory.ts と sprint-views.ts が実行時に互いを読む循環を外す。入力検証の positiveInt を
共通の経路層 routes/operation.ts へ移し、両方がそこから直接読む。factory.ts からの再公開は残さず、
sprint-views.ts の FactoryDeps は type import のままにする。影響範囲は routes の 3 ファイルだけで、振る舞いは変えない。
Item-Id: I-002
Impl-Runtime: claude
Impl-Model: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5
---
factory/api/src/routes/factory.ts | 8 +-------
factory/api/src/routes/operation.ts | 6 ++++++
factory/api/src/routes/sprint-views.ts | 4 ++--
3 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/factory/api/src/routes/factory.ts b/factory/api/src/routes/factory.ts
index 758a237fd..ccc7cdb08 100644
--- a/factory/api/src/routes/factory.ts
+++ b/factory/api/src/routes/factory.ts
@@ -14,7 +14,7 @@ import type { RunControl } from "../runs/control.ts";
import type { LoopControl } from "../runs/loops.ts";
import type { RunsTable } from "../runs/table.ts";
import { parseIdempotencyKey } from "../idempotency.ts";
-import { registerList, registerOperation } from "./operation.ts";
+import { positiveInt, registerList, registerOperation } from "./operation.ts";
import { registerSprintViews, sprintRunList } from "./sprint-views.ts";
export interface FactoryDeps {
@@ -30,12 +30,6 @@ export interface FactoryDeps {
reader: ProgressReader;
}
-export function positiveInt(raw: unknown, message = "リポジトリの ID の形ではありません"): number {
- const n = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
- if (!Number.isInteger(n) || (n as number) < 1) throw new InvalidRequestError(message);
- return n as number;
-}
-
function idOf(raw: unknown, message: string): string {
if (typeof raw !== "string" || raw === "") throw new InvalidRequestError(message);
return raw;
diff --git a/factory/api/src/routes/operation.ts b/factory/api/src/routes/operation.ts
index 989f28f90..837d78f28 100644
--- a/factory/api/src/routes/operation.ts
+++ b/factory/api/src/routes/operation.ts
@@ -9,6 +9,12 @@ import { runElements, type ElementResult } from "./elements.ts";
export type Element = Record;
+export function positiveInt(raw: unknown, message = "リポジトリの ID の形ではありません"): number {
+ const n = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
+ if (!Number.isInteger(n) || (n as number) < 1) throw new InvalidRequestError(message);
+ return n as number;
+}
+
export interface ElementHandler {
// 要求全体の前に 1 度だけ行う(条件の照合など)。投げると要求全体を断る
prepare?: (c: Context, ctx: AccountContext | null) => Promise;
diff --git a/factory/api/src/routes/sprint-views.ts b/factory/api/src/routes/sprint-views.ts
index ae8559ea5..941a1103a 100644
--- a/factory/api/src/routes/sprint-views.ts
+++ b/factory/api/src/routes/sprint-views.ts
@@ -9,8 +9,8 @@ import type { RankingTable } from "../github/port.ts";
import type { SprintRun } from "../runs/model.ts";
import { DETAIL_STEP_LIMIT, deriveDetail, deriveRefinements, deriveStepDetail, listRowExtras, stepRows } from "../views/derive.ts";
import { mapLimit } from "./elements.ts";
-import { positiveInt, type FactoryDeps } from "./factory.ts";
-import { registerOperation } from "./operation.ts";
+import type { FactoryDeps } from "./factory.ts";
+import { positiveInt, registerOperation } from "./operation.ts";
// 一覧の行ごとの読み出しを並べる数の上限
const LIST_CONCURRENCY = 10;
From 50524082ed19664f59f61ed9cd061e84d8c89ad4 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Thu, 5 Nov 2026 00:36:44 +0000
Subject: [PATCH 14/16] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?=
=?UTF-8?q?=20factory/api/src/runs/memory-table.ts#MemoryRunsTable.createR?=
=?UTF-8?q?un?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
createRun の拒否の判定を refuseCreate に、全条件が通った後の書き込みを commitCreate に抽出した。
拒否条件の順序と「どれかが当たれば何も書かない」境界をメソッドの切れ目で示す。振る舞いは変えていない。
Item-Id: I-003
Impl-Runtime: claude
Impl-Model: claude-opus-5-5
Co-Authored-By: Claude Opus 5.5
---
factory/api/src/runs/memory-table.ts | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/factory/api/src/runs/memory-table.ts b/factory/api/src/runs/memory-table.ts
index 32368112c..cd63b6635 100644
--- a/factory/api/src/runs/memory-table.ts
+++ b/factory/api/src/runs/memory-table.ts
@@ -148,6 +148,14 @@ export class MemoryRunsTable implements RunsTable {
async createRun(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): Promise {
await this.tick();
+ const refused = this.refuseCreate(run, expected, guards);
+ if (refused) return refused;
+ this.commitCreate(run, job, expected, guards);
+ return "created";
+ }
+
+ // 作成を拒む理由。条件はこの順で確かめ、どれか 1 つでも当たれば何も書かない
+ private refuseCreate(run: SprintRun, expected: Slots, guards?: CreateRunGuards): CreateRunResult | null {
const repoKey = regKey(run.accountId, run.repositoryId);
if ((this.accountSlots.get(run.accountId) ?? 0) !== expected.account) return "slots";
if ((this.repositorySlots.get(repoKey) ?? 0) !== expected.repository) return "slots";
@@ -160,6 +168,12 @@ export class MemoryRunsTable implements RunsTable {
if (guards.loop && !this.loopFits(guards.loop)) return "loop_changed";
if (guards.idempotency && !this.idempotencyFree(guards.idempotency)) return "duplicate";
}
+ return null;
+ }
+
+ // 全条件が通った後の書き込み
+ private commitCreate(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): void {
+ const repoKey = regKey(run.accountId, run.repositoryId);
this.accountSlots.set(run.accountId, expected.account + 1);
this.repositorySlots.set(repoKey, expected.repository + 1);
if (guards) {
@@ -173,7 +187,6 @@ export class MemoryRunsTable implements RunsTable {
}
this.runs.set(run.sprintRunId, structuredClone({ ...run, ...(guards ? { legacy: guards.legacy } : {}) }));
this.jobs.set(job.jobId, structuredClone(job));
- return "created";
}
async getRun(sprintRunId: string) {
From b31183dcf521b747d21b6c8977c73e4fb980f489 Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Thu, 5 Nov 2026 01:03:31 +0000
Subject: [PATCH 15/16] =?UTF-8?q?fix(factory):=20=E5=86=AA=E7=AD=89?=
=?UTF-8?q?=E3=81=AE=E5=86=8D=E9=80=81=E3=81=AE=E3=82=A2=E3=82=AB=E3=82=A6?=
=?UTF-8?q?=E3=83=B3=E3=83=88=E7=85=A7=E5=90=88=E3=83=BB=E7=AB=B6=E5=90=88?=
=?UTF-8?q?=E6=99=82=E3=81=AE=E5=86=8D=E7=A2=BA=E8=AA=8D=E3=83=BB=E8=A6=81?=
=?UTF-8?q?=E7=B4=A0=E3=81=94=E3=81=A8=E3=81=AE=20503=E3=83=BB=E7=94=BB?=
=?UTF-8?q?=E9=9D=A2=E3=81=AE=E9=8D=B5=E3=81=AE=E6=8C=81=E3=81=A1=E8=B6=8A?=
=?UTF-8?q?=E3=81=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- startSprintRun と startLoop の再送で、鍵が指す実行・ループが要求のアカウントに属さなければ forbidden にする
- 同じ鍵の先行の要求が途中で作り終えたときは、枠・持ち分・名前の断りより先にその結果を返す
- 要素の中の ApiError でない例外もその要素の 503 にし、ほかの要素の成功の結果を残す(#2384 の決定 3)
- 画面の作成は、結果が分からない失敗(503・通信の失敗)の後の同じ中身の送り直しで同じ冪等の鍵を使う
Co-Authored-By: Claude Opus 5.5
---
factory/api/src/routes/elements.test.ts | 20 +++++++++++
factory/api/src/routes/elements.ts | 10 +++---
factory/api/src/routes/factory.test.ts | 27 ++++++++++++++
factory/api/src/runs/control.ts | 48 +++++++++++++++++--------
factory/api/src/runs/loops.ts | 46 +++++++++++++++++-------
factory/web/src/api.test.ts | 25 ++++++++++++-
factory/web/src/api.ts | 25 ++++++++++---
7 files changed, 165 insertions(+), 36 deletions(-)
create mode 100644 factory/api/src/routes/elements.test.ts
diff --git a/factory/api/src/routes/elements.test.ts b/factory/api/src/routes/elements.test.ts
new file mode 100644
index 000000000..4bc2712e6
--- /dev/null
+++ b/factory/api/src/routes/elements.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { ConflictError } from "../errors.ts";
+import { runElements } from "./elements.ts";
+
+describe("要素ごとの実行(#2384 の決定 3)", () => {
+ it("保存の不調などの例外もその要素の 503 にし、ほかの要素の成功の結果を残す", async () => {
+ let done = 0;
+ const results = await runElements([1, 2, 3], async (n) => {
+ if (n === 2) throw new Error("ProvisionedThroughputExceeded");
+ if (n === 3) throw new ConflictError("loop_busy", "busy");
+ await new Promise((r) => setTimeout(r, 5));
+ done++;
+ return n;
+ });
+ expect(done).toBe(1);
+ expect(results[0]).toEqual({ ok: true, value: 1 });
+ expect(results[1]).toMatchObject({ ok: false, status: 503 });
+ expect(results[2]).toMatchObject({ ok: false, status: 409, error: { code: "loop_busy" } });
+ });
+});
diff --git a/factory/api/src/routes/elements.ts b/factory/api/src/routes/elements.ts
index eae8fcb43..c54aefaeb 100644
--- a/factory/api/src/routes/elements.ts
+++ b/factory/api/src/routes/elements.ts
@@ -1,6 +1,6 @@
// 要素ごとの実行(#2384 の決定 3・不変条件 I13)。要素を並べる数 10 で順にユースケースへ渡し、
// ApiError を要素の結果に包む。応答は入力と同じ順・同じ数で、失敗の要素はその要素のデータを持たない
-import { ApiError } from "../errors.ts";
+import { AccountUnavailableError, ApiError } from "../errors.ts";
// 要素の読み出しを並べる数の上限(一覧の行の読み出しと同じ値)
export const ELEMENT_CONCURRENCY = 10;
@@ -22,14 +22,16 @@ export async function mapLimit(items: T[], limit: number, fn: (item: T, in
return out;
}
-// ApiError でない失敗(保存の不調など)はそのまま投げ、要求全体の 503 にする
+// ApiError でない失敗(保存の不調など)も、要求全体でなくその要素の 503 にする(決定 3)。1 要素の失敗で
+// 全体を投げると、ほかの要素の書き込みが進んだまま成功の結果(1 度しか返せない委任の値など)が失われる。
+// 503 の本文は要求全体の失敗(handlers/api.ts の onError)と同じにする
export function runElements(elements: E[], fn: (element: E) => Promise): Promise {
return mapLimit(elements, ELEMENT_CONCURRENCY, async (element) => {
try {
return { ok: true as const, value: await fn(element) };
} catch (err) {
- if (!(err instanceof ApiError)) throw err;
- return { ok: false as const, status: err.status, error: err.body().error };
+ const known = err instanceof ApiError ? err : new AccountUnavailableError();
+ return { ok: false as const, status: known.status, error: known.body().error };
}
});
}
diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts
index 7655d639a..eea325c60 100644
--- a/factory/api/src/routes/factory.test.ts
+++ b/factory/api/src/routes/factory.test.ts
@@ -215,6 +215,33 @@ describe("作成の冪等の鍵(#2384 の受け入れ条件 9・I14)", () =>
expect(h.table.idempotency.size).toBe(1);
});
+ it("別のアカウントへ同じ鍵を送ると、元の実行・ループを返さずに forbidden", async () => {
+ const acc = await ready();
+ const team = (await op("createTeam", "u-1", { items: [{ name: "チーム", seat_type: "standard", idempotency_key: idem() }] })).body.account_id;
+ await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "cross" }] });
+ await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "cross" }] });
+ expect((await op("startSprintRun", "u-1", { accountId: team, items: [{ repository_id: 101, issues: [1], idempotency_key: "cross" }] })).status).toBe(403);
+ expect((await op("startLoop", "u-1", { accountId: team, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "cross" }] })).status).toBe(403);
+ });
+
+ it("先行の同じ鍵が途中で作り終えたら、枠・名前の断りより先にその結果を返す", async () => {
+ const acc = await ready();
+ const run = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "race" }] });
+ const loop = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "race" }] });
+ // 2 本目の最初の読み出しだけを、先行の要求が書く前の空として返す
+ const real = h.table.getIdempotent.bind(h.table);
+ let blind = 0;
+ h.table.getIdempotent = async (ref, now) => (blind-- > 0 ? null : real(ref, now));
+ blind = 1;
+ const again = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [2], idempotency_key: "race" }] });
+ expect(again.status).toBe(200);
+ expect(again.body.sprint_run_id).toBe(run.body.sprint_run_id);
+ blind = 1;
+ const loopAgain = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "race" }] });
+ expect(loopAgain.status).toBe(200);
+ expect(loopAgain.body.loop_id).toBe(loop.body.loop_id);
+ });
+
it("鍵の形が違えば要素だけを 400 にする", async () => {
const acc = await ready();
expect((await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "空白 あり" }] })).status).toBe(400);
diff --git a/factory/api/src/runs/control.ts b/factory/api/src/runs/control.ts
index c380befe6..5efedf06b 100644
--- a/factory/api/src/runs/control.ts
+++ b/factory/api/src/runs/control.ts
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
import type { JobFinishedEvent, RunJobInstruction } from "@ndf-factory/contracts";
import { judgeLimits } from "../accounts/limits.ts";
import type { AccountContext } from "../accounts/model.ts";
-import { ConflictError, ForbiddenError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts";
+import { ApiError, ConflictError, ForbiddenError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts";
import type { GitHubPort } from "../github/port.ts";
import type { OrganizerWrite, OrgRequest } from "../organizer/model.ts";
import { finishedKey } from "../progress/authorize.ts";
@@ -184,7 +184,6 @@ export class RunControl {
// 開始。確かめの順は 課題の形(400)→ 登録(404)→ 課題(400)→ ループのマイルストーン(409)→ キー(409)→ 導入(409)→
// 使える量(409)→ 版(409)→ 課題の持ち分(409)
async startSprintRun(ctx: AccountContext, input: StartInput): Promise<{ sprint_run_id: string; state: "starting" }> {
- const { table, github } = this.deps;
const repositoryId = input.repositoryId;
if (!Number.isInteger(repositoryId) || (repositoryId as number) < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません");
const issues = input.issues;
@@ -200,30 +199,51 @@ export class RunControl {
const idempotency: IdempotencyRef | undefined =
input.idempotencyKey === undefined ? undefined : { userId: ctx.userId, operationId: "startSprintRun", key: input.idempotencyKey };
// 同じ鍵の 2 回目は 1 回目の結果を返し、何も確かめず何も書かない
- const replayed = idempotency ? await table.getIdempotent(idempotency, this.iso()) : null;
- if (replayed) return { sprint_run_id: replayed, state: "starting" };
- const registration = await table.getRegistration(ctx.accountId, repositoryId as number);
+ const replayed = idempotency ? await this.replayRun(ctx, idempotency) : null;
+ if (replayed) return replayed;
+ try {
+ return await this.createSprintRun(ctx, repositoryId as number, issues as number[], idempotency);
+ } catch (err) {
+ // 同じ鍵の先行の要求が途中で作り終えていれば、枠・持ち分の断りより先にその実行を返す
+ const won = idempotency && err instanceof ApiError ? await this.replayRun(ctx, idempotency) : null;
+ if (won) return won;
+ throw err;
+ }
+ }
+
+ // 期限の中の同じ鍵が作った実行。別のアカウントの実行なら返さずに forbidden(鍵は利用者ごとで、アカウントをまたぐ)
+ private async replayRun(ctx: AccountContext, ref: IdempotencyRef): Promise<{ sprint_run_id: string; state: "starting" } | null> {
+ const runId = await this.deps.table.getIdempotent(ref, this.iso());
+ if (!runId) return null;
+ const run = await this.deps.table.getRun(runId);
+ if (!run || run.accountId !== ctx.accountId) throw new ForbiddenError();
+ return { sprint_run_id: runId, state: "starting" };
+ }
+
+ private async createSprintRun(
+ ctx: AccountContext,
+ repositoryId: number,
+ issues: number[],
+ idempotency: IdempotencyRef | undefined,
+ ): Promise<{ sprint_run_id: string; state: "starting" }> {
+ const { table, github } = this.deps;
+ const registration = await table.getRegistration(ctx.accountId, repositoryId);
if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません");
- const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues as number[]);
+ const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues);
if (check.invalid.length > 0) throw new InvalidRequestError("開いている課題ではありません", "invalid_issues", { numbers: check.invalid });
// 動いているループのマイルストーンに属する課題は断る(ループの周回の選定と取り合う。決定 11)
const titles = [...new Set(Object.values(check.milestones ?? {}).filter((t): t is string => typeof t === "string" && t !== ""))];
await this.refuseIssuesInLoop(ctx.accountId, registration.repositoryId, titles, check.milestones ?? {});
const created = await this.admit(ctx, registration, {
- issues: issues as number[],
+ issues: issues,
milestoneTitles: titles,
milestonesOf: check.milestones ?? {},
start: "new",
...(idempotency ? { idempotency } : {}),
});
- if (created === "duplicate") {
- // 同じ鍵の作成が同時に走り、先に書いた方が勝った
- const won = await table.getIdempotent(idempotency!, this.iso());
- if (!won) throw new ServiceUnavailableError("sprint_run_unavailable");
- return { sprint_run_id: won, state: "starting" };
- }
- if (created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable");
+ // duplicate は同じ鍵の作成が同時に走り、先に書いた方が勝った。勝った実行は呼び出し元が鍵から引き直す
+ if (created === "duplicate" || created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable");
return { sprint_run_id: created.sprintRunId, state: "starting" };
}
diff --git a/factory/api/src/runs/loops.ts b/factory/api/src/runs/loops.ts
index aa470b992..fa1cc337d 100644
--- a/factory/api/src/runs/loops.ts
+++ b/factory/api/src/runs/loops.ts
@@ -4,7 +4,7 @@
import { randomUUID } from "node:crypto";
import type { LoopLapClosedPayload, LoopStoppedPayload } from "@ndf-factory/contracts";
import type { AccountContext, AccountRecord, SeatRecord } from "../accounts/model.ts";
-import { ApiError, ConflictError, InvalidRequestError, NotFoundError } from "../errors.ts";
+import { ApiError, ConflictError, ForbiddenError, InvalidRequestError, NotFoundError } from "../errors.ts";
import type { ProgressReader } from "../progress/read.ts";
import type { LoopHooks, RunControl } from "./control.ts";
import {
@@ -183,7 +183,6 @@ export class LoopControl {
// F1: 始める。マイルストーンの持ち分とループの行を 1 回で書き、続けて最初の周回を起こす(枠で待つときも 201)
async startLoop(ctx: AccountContext, repositoryId: number, body: Record): Promise<{ loop_id: string; state: DevLoop["state"] }> {
- const { table } = this.deps;
const name = body.name;
if (typeof name !== "string" || !LOOP_NAME.test(name)) throw new InvalidRequestError("ループの名前は英数字と - _ で 64 字までにしてください");
const milestones = body.milestones;
@@ -202,6 +201,31 @@ export class LoopControl {
body.idempotency_key === undefined ? undefined : { userId: ctx.userId, operationId: "startLoop", key: parseIdempotencyKey(body.idempotency_key) };
const replayed = idempotency ? await this.replayLoop(ctx, idempotency) : null;
if (replayed) return replayed;
+ let loop: DevLoop;
+ try {
+ loop = await this.insertLoop(ctx, repositoryId, name, milestones as string[], budget, idempotency);
+ } catch (err) {
+ // 同じ鍵の先行の要求が途中で作り終えていれば、名前・持ち分の断りより先にそのループを返す
+ const won = idempotency && err instanceof ApiError ? await this.replayLoop(ctx, idempotency) : null;
+ if (won) return won;
+ throw err;
+ }
+ // 始めるのは利用者の操作のため、local でも最初の周回を起こす(I52)
+ await this.startNextLap(ctx.accountId, loop.loopId, true);
+ const after = await this.deps.table.getLoop(ctx.accountId, loop.loopId);
+ return { loop_id: loop.loopId, state: after?.state ?? loop.state };
+ }
+
+ // ループの行とマイルストーンの持ち分を 1 回で書く。断るときは ApiError を投げる
+ private async insertLoop(
+ ctx: AccountContext,
+ repositoryId: number,
+ name: string,
+ milestones: string[],
+ budget: LoopBudget,
+ idempotency: IdempotencyRef | undefined,
+ ): Promise {
+ const { table } = this.deps;
const registration = await table.getRegistration(ctx.accountId, repositoryId);
if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません");
const same = (await table.listLoops(ctx.accountId)).find((l) => l.repositoryId === repositoryId && l.name === name && !isTerminalLoop(l.state));
@@ -212,7 +236,7 @@ export class LoopControl {
accountId: ctx.accountId,
repositoryId,
name,
- milestones: milestones as string[],
+ milestones,
budget,
state: "between_laps",
waitReason: null,
@@ -233,11 +257,8 @@ export class LoopControl {
lastRunAt: null,
};
const created = await table.createLoop(loop, idempotency && claimOf(idempotency, loop.loopId, now));
- if (created === "duplicate") {
- const won = await this.replayLoop(ctx, idempotency!);
- if (!won) throw new ConflictError("loop_busy", "ループの状態が変わりました。やり直してください");
- return won;
- }
+ // 同じ鍵の作成が同時に走り、先に書いた方が勝った。勝ったループは呼び出し元が鍵から引き直す
+ if (created === "duplicate") throw new ConflictError("loop_busy", "ループの状態が変わりました。やり直してください");
if (created !== "created") {
const share = await table.getMilestoneShare(ctx.accountId, repositoryId, created.conflict);
const other = share ? await table.getLoop(ctx.accountId, share.loopId) : null;
@@ -247,18 +268,17 @@ export class LoopControl {
loop_name: other?.name ?? null,
});
}
- // 始めるのは利用者の操作のため、local でも最初の周回を起こす(I52)
- await this.startNextLap(ctx.accountId, loop.loopId, true);
- const after = await table.getLoop(ctx.accountId, loop.loopId);
- return { loop_id: loop.loopId, state: after?.state ?? loop.state };
+ return loop;
}
// 期限の中の同じ鍵が作ったループの今の状態
private async replayLoop(ctx: AccountContext, ref: IdempotencyRef): Promise<{ loop_id: string; state: DevLoop["state"] } | null> {
const loopId = await this.deps.table.getIdempotent(ref, this.iso());
if (!loopId) return null;
+ // 鍵は利用者ごとでアカウントをまたぐ。別のアカウントのループなら返さずに forbidden
const loop = await this.deps.table.getLoop(ctx.accountId, loopId);
- return { loop_id: loopId, state: loop?.state ?? "between_laps" };
+ if (!loop) throw new ForbiddenError();
+ return { loop_id: loopId, state: loop.state };
}
// 周回の開始(決定 2、I44・I52)。between_laps で今の実行が無いループだけを起こす。条件で落ちれば読み直して 3 回まで
diff --git a/factory/web/src/api.test.ts b/factory/web/src/api.test.ts
index b3aa988b1..69d8edf5a 100644
--- a/factory/web/src/api.test.ts
+++ b/factory/web/src/api.test.ts
@@ -1,7 +1,7 @@
// 画面の API クライアントと契約の対応(#2384 の受け入れ条件 1、性能の条件)
import { describe, expect, it } from "vitest";
import { MAX_ITEMS, OPERATIONS } from "@ndf-factory/contracts/operations";
-import { createApi, type Api } from "./api.ts";
+import { ApiFailure, createApi, type Api } from "./api.ts";
import type { Auth } from "./auth.ts";
const API_URL = "https://api.example.test";
@@ -90,6 +90,29 @@ describe("画面の API クライアントと契約(受け入れ条件 1)",
expect(keys[0]).toMatch(/^[0-9A-Za-z-]{1,64}$/);
expect(keys[0]).not.toBe(keys[1]);
});
+
+ it("結果が分からない失敗(503・通信の失敗)の後の同じ中身の送り直しは同じ鍵を使い、断りの後は変える", async () => {
+ const keys: string[] = [];
+ const replies = [503, 0, 200, 409, 200];
+ const doFetch: typeof fetch = async (_input, init) => {
+ keys.push((JSON.parse(String(init?.body)) as { idempotency_key: string }[])[0]!.idempotency_key);
+ const status = replies.shift()!;
+ if (status === 0) throw new TypeError("Failed to fetch");
+ if (status === 503) return Response.json({ error: { code: "audit_unavailable", message: "" } }, { status });
+ if (status === 409) return Response.json([{ ok: false, status, error: { code: "limits_exceeded", message: "" } }]);
+ return Response.json([{ ok: true, value: {} }]);
+ };
+ const api = createApi(API_URL, auth, doFetch);
+ await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(ApiFailure);
+ await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(TypeError);
+ await api.createTeam("t", "standard");
+ await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(ApiFailure);
+ await api.createTeam("t", "standard");
+ expect(keys[1]).toBe(keys[0]);
+ expect(keys[2]).toBe(keys[0]);
+ expect(keys[3]).not.toBe(keys[0]);
+ expect(keys[4]).not.toBe(keys[3]);
+ });
});
describe("要求の回数(非機能の条件「性能・拡張性」)", () => {
diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts
index 873d61929..f3406809d 100644
--- a/factory/web/src/api.ts
+++ b/factory/web/src/api.ts
@@ -341,6 +341,23 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (.
// 要素 1 個を送り、その結果の値を返す。失敗の要素は ApiFailure にする
const one = async (id: OperationId, accountId: string | null, item: Record) =>
valueOf((await request[]>(id, accountId, { items: [item] }))[0]);
+ // 作成(新しい識別子を作る操作)。冪等の鍵は送る中身ごとに持ち、結果が分からない失敗(503・通信の失敗)の後に
+ // 同じ中身を送り直すときは同じ鍵を使う(保存の後に記録の追記が落ちた 503 で、作り直しを 2 重にしない。#2384 の決定 4)。
+ // 成功か、断りの結果(503 以外の失敗)が届いたら鍵を捨て、次の送信は新しい作成にする
+ const pendingKeys = new Map();
+ const create = async (id: OperationId, accountId: string | null, item: Record) => {
+ const slot = JSON.stringify([id, accountId, item]);
+ const idempotency_key = pendingKeys.get(slot) ?? newKey();
+ pendingKeys.set(slot, idempotency_key);
+ try {
+ const value = await one(id, accountId, { ...item, idempotency_key });
+ pendingKeys.delete(slot);
+ return value;
+ } catch (err) {
+ if (err instanceof ApiFailure && err.status !== 503) pendingKeys.delete(slot);
+ throw err;
+ }
+ };
// 鍵の query で指す取得と削除。要素の結果を入力と同じ順に返す
const select = async (id: OperationId, accountId: string | null, key: string, values: (string | number)[], query: Record = {}) => {
const out: ElementResult[] = [];
@@ -354,7 +371,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (.
return {
me: async () => (await list("getMe", null))[0]!,
account: async (id) => (await values("getAccount", null, "account_id", [id]))[0]!,
- createTeam: (name, seat_type) => one("createTeam", null, { name, seat_type, idempotency_key: newKey() }),
+ createTeam: (name, seat_type) => create("createTeam", null, { name, seat_type }),
setExecutionMode: (id, execution_mode) => one("setExecutionMode", null, { account_id: id, execution_mode }),
addSeat: (id, user_id, seat_type) => one("addSeat", id, { user_id, seat_type }),
changeSeat: (id, user_id, seat_type) => one("changeSeat", id, { user_id, seat_type }),
@@ -375,7 +392,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (.
valueOf((await request[]>("deleteModelCredential", id))[0]);
},
issueCandidates: (id, repository_id, exclude) => one("postIssueCandidates", id, { repository_id, exclude }),
- startSprintRun: (id, repository_id, issues) => one("startSprintRun", id, { repository_id, issues, idempotency_key: newKey() }),
+ startSprintRun: (id, repository_id, issues) => create("startSprintRun", id, { repository_id, issues }),
sprintRuns: async (id, repositoryId) => ({
sprint_runs: await list("listSprintRuns", id, repositoryId === undefined ? {} : { repository_id: repositoryId }),
}),
@@ -386,7 +403,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (.
cancelSprintRun: (id, sprint_run_id) => one("cancelSprintRun", id, { sprint_run_id }),
updateRepository: (id, repository_id, max_concurrent_runs) => one("updateRepository", id, { repository_id, max_concurrent_runs }),
loops: async (id, repositoryId) => ({ loops: await list("listLoops", id, { repository_id: repositoryId }) }),
- startLoop: (id, repository_id, input) => one("startLoop", id, { repository_id, ...input, idempotency_key: newKey() }),
+ startLoop: (id, repository_id, input) => create("startLoop", id, { repository_id, ...input }),
// 要素の数によらず 1 回の要求で読む。無いループ(loop_not_found)の要素は返さない
loopDetails: async (id, loopIds) => {
const results = await select("getLoop", id, "loop_id", loopIds);
@@ -397,7 +414,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (.
requestNextLap: (id, loop_id) => one("startNextLap", id, { loop_id }),
answerLoop: (id, loop_id, issue, decision, pr) => one("answerLoopPending", id, { loop_id, issue, decision, ...(pr === undefined ? {} : { pr }) }),
delegations: () => list("listDelegations", null),
- createDelegation: (input) => one("createDelegation", null, { ...input, idempotency_key: newKey() }),
+ createDelegation: (input) => create("createDelegation", null, { ...input }),
revokeDelegation: async (credentialId) => (await values("revokeDelegation", null, "credential_id", [credentialId]))[0]!,
};
}
From 644ce77f7e499f369cd27fd6955bd494817516ec Mon Sep 17 00:00:00 2001
From: "takemi.ohama"
Date: Thu, 5 Nov 2026 01:13:13 +0000
Subject: [PATCH 16/16] =?UTF-8?q?fix(factory):=20=E5=A7=94=E4=BB=BB?=
=?UTF-8?q?=E3=81=AE=E9=80=81=E3=82=8A=E7=9B=B4=E3=81=97=E3=81=A7=E5=80=A4?=
=?UTF-8?q?=E3=81=8C=E8=BF=94=E3=82=89=E3=81=AA=E3=81=84=E3=81=A8=E3=81=8D?=
=?UTF-8?q?=E3=81=AB=E7=9F=A5=E3=82=89=E3=81=9B=E3=82=8B=EF=BC=88PR=20#251?=
=?UTF-8?q?8=20round=202=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
結果が分からない失敗の後の再送は同じ冪等の鍵で前の発行を credential: null で返す。
画面が黙って受けると使えない有効な資格情報が残るため、取り消して発行し直すよう知らせる。
Co-Authored-By: Claude Opus 5.5
---
factory/web/src/i18n/ja.json | 1 +
factory/web/src/pages/DelegationsPage.tsx | 3 +++
factory/web/src/pages/delegations.test.tsx | 15 +++++++++++++--
3 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/factory/web/src/i18n/ja.json b/factory/web/src/i18n/ja.json
index 566cfd820..a28c0907c 100644
--- a/factory/web/src/i18n/ja.json
+++ b/factory/web/src/i18n/ja.json
@@ -408,6 +408,7 @@
"value": "資格情報の値",
"registerExample": "MCP のクライアントへの登録の例",
"close": "閉じる",
+ "valueUnavailable": "値はもう表示できません。この資格情報を取り消して発行し直してください",
"list": "発行した資格情報",
"empty": "まだ委任していません",
"createdAt": "発行",
diff --git a/factory/web/src/pages/DelegationsPage.tsx b/factory/web/src/pages/DelegationsPage.tsx
index 7d22925cc..64e6757a8 100644
--- a/factory/web/src/pages/DelegationsPage.tsx
+++ b/factory/web/src/pages/DelegationsPage.tsx
@@ -63,6 +63,9 @@ export function DelegationsPage() {
const { delegation, credential } = await api.createDelegation({ client_name: name, scope, expires_in_seconds: expires });
setIssued(credential);
setName("");
+ // 結果が分からない失敗の後の送り直しは、同じ鍵で前の発行を返し、値を返さない(値は発行の 1 度だけ)。
+ // 黙って受けると、使えない有効な資格情報が残るため、取り消して発行し直すよう知らせる
+ if (credential === null) setMessage(t("delegations.valueUnavailable"));
setRows((prev) => [delegation, ...(prev ?? []).filter((d) => d.credential_id !== delegation.credential_id)]);
} catch (err) {
setMessage(onFailure(err));
diff --git a/factory/web/src/pages/delegations.test.tsx b/factory/web/src/pages/delegations.test.tsx
index 52b4b043c..5087a2226 100644
--- a/factory/web/src/pages/delegations.test.tsx
+++ b/factory/web/src/pages/delegations.test.tsx
@@ -10,10 +10,10 @@ afterEach(cleanup);
const tokyo: LocaleSettings = { language: "ja", locale: "ja-JP", timeZone: "Asia/Tokyo" };
-function show(search = "") {
+function show(search = "", services = createDummyServices(search)) {
render(
-
+
,
);
}
@@ -35,6 +35,17 @@ describe("エージェントへの委任(#2384)", () => {
expect(await within(rows[0]!).findByText("取り消し済み")).toBeTruthy();
});
+ it("送り直しで前の発行が値なしで返ったら、値を出せないことと取り消して発行し直すことを知らせる", async () => {
+ const services = createDummyServices("");
+ const create = services.api.createDelegation;
+ services.api.createDelegation = async (input) => ({ ...(await create(input)), credential: null });
+ show("", services);
+ await userEvent.type(await screen.findByLabelText("クライアントの名前"), "手元の Claude Code");
+ await userEvent.click(screen.getByRole("button", { name: "発行する" }));
+ expect(await screen.findByText("値はもう表示できません。この資格情報を取り消して発行し直してください")).toBeTruthy();
+ expect(screen.queryByText(/^ndfd_/)).toBeNull();
+ });
+
it("名前が無ければ発行しない", async () => {
show();
await userEvent.click(await screen.findByRole("button", { name: "発行する" }));