AI Agent Workstationを4レイヤー構成へ段階的にリファクタリングする

Todo Schedulerを4レイヤー構成へ段階的にリファクタリングする

Todo Schedulerは、タスク管理アプリとして始めたものの、現在はAIエージェントがタスクを計画し、複数の実行環境へ処理を委譲する基盤へ変わりつつあります。

現在の主な構成要素は次のとおりです。

  • タスクと依存関係の管理
  • Background AIによるタスク実行
  • Runnerへのジョブ割り当て
  • ワークスペースをまたぐタスク委譲
  • Task Planによる子タスク生成
  • 実行結果や成果物の集約
  • Kubernetes上のAI実行環境
  • Ciliumによる通信制御
  • 内部Gitおよびパッケージミラー

機能が増えた結果、バックエンド、フロントエンド、Runnerの各実装が大きくなり、責務の境界が曖昧になってきました。

そこで現在、既存のAPI、データベース、画面挙動を維持しながら、段階的なリファクタリングを進めています。

この記事では、現時点で完了している改善と、これから取り組む課題を整理します。


SandboxではなくWorkstationと呼ぶ

これまで、Kubernetes上のAI実行環境を「Sandbox」と表現することがありました。

しかし、現在のRunnerは完全な使い捨て環境ではありません。

Runnerは次のような永続状態を持っています。

  • Gitリポジトリ
  • 作業ディレクトリ
  • Codex CLIの認証情報
  • パッケージキャッシュ
  • Runner固有の設定
  • ジョブをまたいで利用するPersistentVolume

そのため、現在の実態はSandboxよりも、制約付きのWorkstationに近いと考えています。

現在は、次のように表現しています。

Kubernetes上に構築した、ネットワーク、権限、依存取得を制限した永続型AI Agent Workstation

この環境は、悪意あるコードを完全に封じ込める高保証Sandboxではありません。

一方で、通常の開発端末よりも権限と通信先を強く制限し、AIエージェントが継続的に開発作業を行える環境を目指しています。


バックエンドを4レイヤーへ分割

バックエンドでは、次の4レイヤーを基本構成としています。

Presentation

Application

Domain

Infrastructure

Application Port

具体的なディレクトリ構成は次のとおりです。

backend/
├── main.go
└── internal/
    ├── domain/
    ├── application/
    ├── infrastructure/
    └── presentation/

Domain

Domainには、業務上の状態や規則を置きます。

主な対象は次のとおりです。

  • タスクの状態遷移
  • Task Plan
  • タスク依存関係
  • PERT計算
  • 親タスクの集約条件
  • Cross-workspace委譲ルール
  • Runnerジョブの状態

Domainは、HTTP、SQLite、環境変数、ブラウザなどの外部要素を知りません。

Application

Applicationには、ユースケースと処理順を置きます。

例えば次のような処理です。

  • Task Planを作成する
  • 子タスクを登録する
  • 依存関係を解決する
  • 実行可能なジョブをqueueへ送る
  • Cross-workspaceタスクを委譲する
  • 子タスクの完了後に親タスクを集約する
  • 中断された計画を復旧する

Repository、Clock、ID生成、通知などはPortとして受け取ります。

Infrastructure

Infrastructureは、外部システムとの接続を担当します。

  • SQLite
  • Git
  • ファイルシステム
  • 通知
  • Runner API
  • 時刻やID生成の実装

業務判断はInfrastructureへ置かず、Applicationが定義したPortを実装します。

Presentation

PresentationはHTTP境界を担当します。

  • Requestのdecode
  • Sessionの取得
  • Application commandへの変換
  • HTTP statusとerror responseへの変換
  • Response DTOの生成

SQLiteを直接操作せず、Applicationのユースケースを呼び出します。


Composition Rootを薄くする

最終的には、main.goをComposition Rootとして利用します。

Composition Rootの責務は、具体的な実装を組み立てることだけです。

func main() {
    config := loadConfig()
    application := buildApplication(config)
    server := buildHTTPServer(application)

    server.Run()
}

以前のmain.goは約4,200行あり、HTTP routing、DB操作、状態遷移、通知、workspace管理など、多くの責務を持っていました。

現在は約1,800行まで縮小しています。

まだ中央routingや互換処理は残っていますが、段階的にPresentationやApplicationへ移動しています。

一度に全面書き換えを行わず、既存処理を一時的なFacadeとして残しながら移行しています。


依存方向をテストで固定する

ディレクトリを分けるだけでは、レイヤー構成は維持できません。

そのため、Architecture Testを追加しました。

現在は、次の依存関係を検査しています。

Domain
  → Domainのみ

Application
  → Domain / Application

Infrastructure
  → Domain / Application / Infrastructure

Presentation
  → Domain / Application / Presentation

さらに、DomainとApplicationから次の利用を禁止しています。

  • database/sql
  • net/http
  • Infrastructure package
  • Presentation package
  • 外部サービス固有の実装

これにより、Domainから直接SQLiteを呼び出すような逆方向の依存を検出できます。

レイヤー構成は、図に描くだけでは維持できません。CIで壊れたことを検出できて初めて、設計上の制約として機能します。


フロントエンドも4レイヤーへ分割

フロントエンドも、同じ依存方向を参考にしています。

frontend/public/
├── app.js
└── js/
    ├── domain/
    ├── application/
    ├── infrastructure/
    └── presentation/

Domain

ブラウザに依存しない純粋な処理を置きます。

  • PERT計算
  • タスク依存関係
  • 入力値の正規化
  • タスク状態の判定
  • 日付や期間の計算

Application

ユーザー操作に対応するユースケースを置きます。

  • タスクを読み込む
  • タスクを保存する
  • ワークスペースを切り替える
  • Task Planを取得する
  • AI実行結果を更新する

ApplicationはDOMを直接操作しません。

Infrastructure

ブラウザや外部APIとの接続を担当します。

  • fetch
  • localStorage
  • EventSource
  • Notification API
  • Browser API

Presentation

画面表示とDOMイベントを担当します。

  • Task Form
  • Task Tree
  • Schedule
  • Settings
  • AI Runs
  • Workspace画面

Presentationから直接fetchlocalStorageを呼ばないようにしています。


app.jsを縮小する

以前のapp.jsは約1,900行ありました。

現在は約800行まで縮小しています。

まだ次の責務が残っています。

  • DOM要素の取得
  • Featureの配線
  • Routerの初期化
  • 互換用の委譲関数
  • 初期データの読み込み

最終的には、次のようなComposition Rootへ近づける予定です。

const infrastructure = createInfrastructure();
const application = createApplication(infrastructure);
const presentation = createPresentation(application);

presentation.start();

フロントエンドでは、すべての機能に無理にDomain層を作ることはしません。

単純なモーダルの開閉やタブ切り替えに、Domain EntityやRepository Interfaceは不要です。

業務ルールや純粋計算がある機能だけにDomainを設けます。


Infra Runnerを分割する

AI Agent Workstation上で動くRunnerも、以前は1つの大きなスクリプトへ処理が集中していました。

Runnerには次の責務があります。

  • ジョブのclaim
  • Leaseの維持
  • Heartbeat
  • コメント差分の取得
  • Codex CLIの起動
  • Codex sessionのresume
  • JSONL eventの解析
  • Validation
  • Git commitとpush
  • 実行結果の報告
  • Terminal状態の調停

これらを現在、次のような単位へ分割しています。

runner-api-contract.mjs
json-http-port.mjs
runner-control.mjs
runner-attachments.mjs
runner-event-log.mjs
runner-git-port.mjs
runner-lifecycle.mjs
runner-process-port.mjs
runner-reporting.mjs
runner-result-contract.mjs
runner-strategies.mjs
terminal-reconciler.mjs

特に、Scheduler APIとの契約、Git操作、プロセス実行、terminal responseの組み立てを分離しました。

Runner本体のcodex-runner.mjsも、約1,900行から約1,300行まで縮小しています。

まだpoll loopやactive job coordinatorが残っているため、今後さらに分割する予定です。


AI Agent Workstationの隔離も改善

アーキテクチャの整理だけでなく、実行環境の制約も強化しています。

Runner Podには、次の設定を適用しています。

runAsNonRoot
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: drop ALL
seccompProfile: RuntimeDefault
automountServiceAccountToken: false

また、Runnerには次のものを渡していません。

  • Docker Socket
  • Kubernetes用kubeconfig
  • HostPath
  • Privileged権限
  • Operator用token

Cilium NetworkPolicyによって、Runnerの通信先も制限しています。

許可対象は主に次のとおりです。

  • Scheduler
  • 内部Git
  • npm、PyPI、Goの内部ミラー
  • 依存承認サービス
  • 必要なAI API
  • 明示的に許可したMCP

通信を許可するテストだけでなく、次の拒否テストも行っています。

  • Kubernetes APIへ接続できない
  • Public npm registryへ直接接続できない
  • 未許可Podから外部へ通信できない
  • 証明書なしでSchedulerへ接続できない
  • SchedulerからAI APIへ接続できない

テストの現状

現時点のローカル確認では、次のテストが通っています。

App JavaScript tests: 76
Infra tests: 84
Codex Runner tests: 30
Python workspace tests: 13

そのほか、次の静的確認も行っています。

  • JavaScript syntax
  • Bash syntax
  • Go format
  • YAML parse
  • AppとInfra間のAPI contract
  • Runner terminal payload
  • Comment revision
  • Lease
  • Task Plan
  • Cross-workspace delegation

機能を追加するだけでなく、既存挙動を固定するテストを増やしながら移行しています。


現在残っている課題

リファクタリングはまだ完了していません。

主な課題は次のとおりです。

DomainからTransport形式を追い出す

一部のDomainコードには、まだ次の形式が残っています。

map[string]any
encoding/json

Runner APIのpayload互換処理やJSONの正規化は、PresentationまたはApplication境界へ移す必要があります。

Domainには、型付けされた業務上の値だけを渡す形へ変更します。

main.goとapp.jsをさらに縮小する

Composition Rootへ向けた移行は進んでいますが、互換routingやFeature間のbridgeが残っています。

これらを機能単位で移動し、旧経路を削除します。

ApplicationのClockを統一する

現在、一部のApplication Serviceでは、Clockが指定されていない場合にtime.Now()へfallbackします。

将来的にはComposition Rootから必ずClockを注入し、Application内で暗黙に現在時刻を取得しない構成へ変更します。

Runnerをさらに分割する

Runnerのpoll loop、active job管理、heartbeat、post-processingを、それぞれ独立したCoordinatorへ分離します。

Workstation間のGit分離

現在の内部Gitは、接続可能なRunnerが別のrepository名を指定できる可能性があります。

今後、repository単位の認証やACL、またはGit Serviceの分離を検討します。

Job単位の使い捨て実行

現在のRunnerは永続的なWorkstationです。

より強い隔離が必要になった場合は、次の構成を検討します。

1 Job

1 Ephemeral Pod

Repository clone

Codex execution

Commit / Artifact

Pod削除

今後の進め方

最初のVertical Sliceとして、PERTとタスク依存関係の移行を進めています。

この機能は純粋計算が多く、Domain、Application、Infrastructure、Presentationを小さく一周できるためです。

この移行方法を検証したあと、より複雑な機能へ適用します。

予定している順序は次のとおりです。

  1. PERTとタスク依存関係
  2. Task Plan
  3. Dependency-aware Queue
  4. Parent Aggregation
  5. Cross-workspace Delegation
  6. RecoveryとCompensation
  7. Runner Coordinator

すべてを一度に書き換えるのではなく、機能ごとに次の手順を繰り返します。

既存挙動のテスト追加

Domainルールの抽出

Application Use Caseの作成

Infrastructure Adapterの移行

Presentationの移行

Composition Rootで差し替え

旧経路を削除

まとめ

現在のTodo Schedulerは、単純なタスク管理アプリから、複数のAI Agent Workstationを管理する実行基盤へ変化しています。

機能の成長に合わせて、バックエンド、フロントエンド、Runnerをそれぞれ適した構造へ整理しています。

現在の方針は次のとおりです。

  • バックエンドはDomain、Application、Infrastructure、Presentation
  • フロントエンドも同様の依存方向を採用
  • main.goapp.jsをComposition Rootへ縮小
  • RunnerはPort、State Machine、Pipelineを中心に分割
  • Architecture Testで依存方向を固定
  • 既存API、DB、画面挙動を維持したまま段階移行
  • AI実行環境はSandboxではなく、制約付きWorkstationとして扱う

まだ完成ではありません。

しかし、巨大なファイルからコードを移動するだけではなく、依存方向と責務の境界をテストで維持できる状態には近づいています。

今後も、各Vertical Sliceの移行過程、失敗した設計、テスト戦略、AI Agent Workstationの隔離について記録していく予定です。