Notes
PRメタデータ検証は「空でない」だけでなく許容値を固定する
PR本文の必須セクションをCIで検証するとき、単に「空欄ではない」を条件にすると TBD や曖昧な記載が通ってしまう。有効なID か None のような明示的センチネルだけを許可すると、運用が安定する。
- 弱い検証 — セクションの存在と非空だけを確認する
- 強い検証 — 正規表現で許容値を限定する(例:
ADR-0001形式 orNone) - 転用ルール — 人間が読むメタ情報でも、CIにかけるなら「自由記述」ではなく「限定語彙」を設計する
# 例: ID形式または None のみ許可
grep -Eqi 'ADR-[0-9]{4}|^[[:space:]-]*None[[:space:]]*$'
楽観更新の並び替えは部分パッチで直し、次はorder外部化を試す
今回実際にやったのは、並び替え時の楽観更新を「表示中データで全体を置換する方式」から「既存キャッシュに対する部分パッチ方式」へ変えたこと。これで、未表示要素の順序や存在が壊れる症状を抑えられた。
- 変更前の問題 — 表示中の配列をそのまま全体へ反映してしまい、フィルタ/ページング外の要素に副作用が出る
// NG: visibleRows だけで全体を再構築
setQueryData(key, visibleRows)
- 実際に入れた修正 — id 一致の既存要素だけ差し替える。未表示要素は保持する
const updates = new Map(visibleRows.map((row) => [row.id, row]))
setQueryData(key, (current) =>
current.map((row) => updates.get(row.id) ?? row)
)
今回の学び — 一覧UIの optimistic update は、全体同期ではなく「既存キャッシュへの局所変更」として扱うと崩れにくい
次に試す改善案(未実施) — order だけを外部化して、実体データと順序責務を分離する
// entities と order を分離して管理するイメージ
state = {
entitiesById: Record<string, Item>,
orderedIdsByView: Record<ViewKey, string[]>,
}
この形にすると、DnD の楽観更新は orderedIdsByView だけを更新すればよく、未表示要素や別ビューへの副作用を抑えやすい。次回はこの設計で比較する。
同じデータの2つのstateを同期するより、片方を派生値にする
Reactコンポーネントで「全アイテムのフラットリスト」と「カテゴリ別に分類したリスト」を別々のstateとして持っていた。更新時に片方だけ更新してもう片方を忘れるバグが頻発した。
// Before: 2つの独立したstate
const [items, setItems] = useState<Item[]>([])
const [categorizedItems, setCategorizedItems] = useState<Record<string, Item[]>>({})
// 更新時に両方を手動で同期する必要がある
const handleUpdate = (updated: Item) => {
setItems(prev => prev.map(i => i.id === updated.id ? updated : i))
// ↑ ここだけ更新して setCategorizedItems を忘れると画面に反映されない
}
修正: categorizedItems を正本にして、フラットリストは useMemo で派生させる。
// After: stateは1つ、もう片方は派生値
const [categorizedItems, setCategorizedItems] = useState<Record<string, Item[]>>({})
const allItems = useMemo(
() => Object.values(categorizedItems).flat(),
[categorizedItems]
)
この構造なら setCategorizedItems を更新するだけで allItems も自動的に最新になる。更新ハンドラが増えても「どっちのstateを更新すべきか」で迷わない。
判断基準:
- 2つのstateが同じデータの異なるビューなら、片方を派生値にできないか検討する
- 「更新のたびに2箇所のsetStateを呼ぶ必要がある」コードは、同期漏れのバグを待っている状態
- どちらを正本にするかは「更新の起点がどちらの形式か」で決める。パネルやタブ別に更新するならカテゴリ別を正本にする
agent skillの実行中に判明した課題や改善点はfeedback.log的なファイルに追記してリポジトリに永続化するのが良さそう
定期的にその内容をスキル本体に取り込む習慣を自動化すると良い
パネルUIは一括取得より各パネル独立ロードにする
カンバン風の複数パネルUI(Done / Current / Backlog / Icebox等)で、全データを一括取得→クライアント側で振り分けていた構成を、パネルごとに独立してAPIを叩く方式にリファクタした。
もともとの構成だと、全件取得してからクライアントでフィルタリングしていたため、データ量が増えると初期表示が遅くなり、特定パネルだけページネーションしたいケースに対応できなかった。
リファクタ後の構成:
panelStories: Record<PanelType, Item[]>
panelLoading: Record<PanelType, boolean>
panelErrors: Record<PanelType, string | null>
panelPagination: Record<PanelType, { page: number; hasNext: boolean }>
- パネルごとにステートを分離 — loading / error / pagination を
Record<PanelType, T>で管理。あるパネルのロード中でも他パネルは操作可能 - パネル別のページサイズ — Doneは10件、他は20件のように、パネルの性質に応じたページサイズを設定できる
- フィルタ変更時の全パネルリロード — オーナーやラベルのフィルタが変わったら全パネルをpage=1からリロード。共通クエリパラメータを
buildSharedQuery()で組み立てて各パネルのリクエストに載せる
トレードオフとして、APIリクエスト数は増える(1回→パネル数分)。ただしパネルごとに並列リクエストできるので体感速度はむしろ改善する。パネル間でアイテムを移動した際のローカルステート同期は mergePanelStories() で全パネルの配列をフラットに結合して保持する形にした。
MCPリソースにデフォルトフィルタを入れてエージェントの焦点を絞る
AIエージェントがMCPリソースから作業項目を取得するとき、全件返すとノイズが多く、エージェントが「今やるべきこと」の判断に時間を使ってしまう。リソース側で「今のスプリントの作業だけ」「優先度順に上位N件だけ」を返すようにデフォルトを絞った。
Before:
エージェント → MCPリソース「タスク一覧」 → 全50件返る
→ エージェントが自分で「今のスプリントは…優先度が高いのは…」と判断
→ やっと実装開始
After:
エージェント → MCPリソース「タスク一覧(スプリント=現在, 上位3件)」
→ 3件だけ返る → 先頭から実装開始
やってみてどうだったか:
- エージェントが「どれをやるか」で迷う時間が消え、タスク開始までのレイテンシが明確に改善した
- 「どの作業を優先するか」の判断ロジックがMCPサーバー側に集約されるので、エージェントのスキル定義はシンプルに「一番上をやれ」だけで済む
- 優先度の計算方法を変えたくなったとき、MCPサーバー側だけ変えればよく、エージェント側のプロンプトを修正しなくてよい
- ポイントは「エージェントに判断させない」こと。判断の余地を減らすほどエージェントの行動が安定する
GraphQL Fragment変更時は「overfetchしてたら直して」と伝える
GraphQL Fragmentを変更するPRで、AIアシスタントに「overfetchしていたら直して」と指示するだけで不要フィールドが見つかる。人間が目視で全フィールドの使用箇所を追うのは面倒だが、コード解析はAIの得意領域。
見つかりやすいパターン:
- 使われていないunion型メンバー(
... on DeletedEntity { id }等) - propsとFragmentの両方で取得している重複フィールド
- コンポーネントと離れた場所に定義されたFragment — コロケーションしていないと不要フィールドに気づきにくいので、ついでにファイル配置もコンポーネントに寄せる
ドメインガード関数で不変条件を一元管理する
複数のフィールドの組み合わせでパネルや状態を決定するUIで、ドラッグ&ドロップなどの操作を追加すると矛盾した状態が生まれやすい。パネル判定ロジックが複数箇所に散らばっていたのが原因だった。
解決策として、不変条件をドメインガード関数に集約し、パネル判定も単一の関数に一元化した。
構造:
domain/
item-input.ts ← ドメインガード(不変条件のバリデーション)
item-panel-grouping.ts ← パネル判定の唯一の情報源
ui/
multi-panel-screen.tsx ← ガード関数を呼ぶだけ
ドメインガードの例:
// isArchived=true と activeGroupId!=null は共存できない
function validateItemInput(input: ItemInput) {
if (input.isArchived && input.activeGroupId != null) {
throw new Error("Archived items cannot belong to an active group");
}
}
パネル判定の一元化:
// グルーピングと同じロジックで単一アイテムのパネルも判定する
function determinePanelForItem(item: Item): PanelType {
return groupItemsByPanel([item])[0].panel;
}
// DnDハンドラは source × target のマトリクスで明示的に許可
function handleDragEnd(source: PanelType, target: PanelType) {
const allowed = { Backlog: ["Archive"], Archive: ["Backlog"] };
if (!allowed[source]?.includes(target)) return; // 無効な遷移は無視
// ...
}
なぜこうしたか:
- UI側にパネル判定の逆ロジックを持つと、グルーピング関数との間で不整合が起きる。判定を1箇所にすることで「UIが見ているパネル」と「ドメインが認識するパネル」のズレがなくなる
- ドメインガードを永続化層の手前に置くと、APIからもUIからも同じ不変条件が強制される
- DnDの遷移をマトリクスで管理すると、許可されない遷移を明示的にドキュメントできる
Cloudflare Access applicationを再作成するとaudが変わる
Cloudflare Accessで保護したアプリのAccess applicationを削除→再作成すると、JWTのaud(Audience Tag)が新しい値に変わる。Worker側でaudを検証している場合、環境変数を更新しないと全APIが401になる。
症状がわかりにくいのがハマりポイント。Cloudflare Accessのログイン自体は成功するので「認証は通っている」ように見えるが、Worker側のJWT検証でaud不一致になりAPIが全滅する。フロントエンドは「session expired」的な表示になるため、Access側の問題だと気づきにくい。
// Worker側のJWT検証コード(簡略化)
const payload = await verify(jwt, publicKey);
if (payload.aud !== env.ACCESS_AUD) {
// Access applicationを再作成すると、ここで落ちる
return new Response('Unauthorized', { status: 401 });
}
調査の勘所:
- ブラウザでAccess認証は通るのにAPIが401 →
audの不一致を疑う curl -vでレスポンスヘッダを確認し、Access自体は通過していることを確認する- JWTをデコードして
audの値を現在のAccess applicationのAudience Tagと突き合わせる
ポイント:
- Access applicationを再作成・置換したら、Worker側の
ACCESS_AUD環境変数を必ず更新する - IaCで管理しているなら、applicationリソースの
aud出力をWorker設定に自動連携しておくと安全
