diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5d29770 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci + +on: + push: + pull_request: + +jobs: + checks: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.8" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run CI checks + run: make test-all diff --git a/.gitignore b/.gitignore index 57405f6..42938c9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist *.tsbuildinfo .DS_Store /agentcore +/roboppi # Local AgentCore workflow state .agentcore-loop/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5a08404 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,156 @@ +# AGENTS.md + +This repository ("Roboppi" / package name: `agentcore`) is a Bun + TypeScript (ESM) +codebase that implements an execution-control runtime for agentic workers. + +## Build / Lint / Test + +Prefer `make` targets (CI uses them), but `bun` equivalents work. + +### Install + +```bash +bun install +bun install --frozen-lockfile # CI +``` + +CI currently uses Bun 1.3.8 (see `.github/workflows/ci.yml`). + +### Typecheck (acts as "lint") + +There is no ESLint/Prettier/Biome config today; use TypeScript strict checks + tests. + +```bash +make typecheck +# or +bun run typecheck +``` + +### Tests + +```bash +make test # bun test +make test-unit # bun test test/unit +make test-integration # bun test test/integration +make test-at # bun test tests/at +make test-branch # bash tests/branch/run-branch-verification.sh +make test-all # CI: typecheck + test + test-branch +``` + +Acceptance test helper runner: + +```bash +bash scripts/run-at.sh # typecheck + tests/at +bash scripts/run-at.sh --full # + bun test +``` + +### Run A Single Test + +```bash +bun test test/unit/ipc/protocol.test.ts +bun test test/unit/ipc/protocol.test.ts -t "routes submit_job" # regex on test name +bun test -t "routes submit_job" # match across all tests +bun test --only # only test.only/describe.only +bun test --timeout 20000 test/integration # per-test timeout (ms) +bun test --watch test/unit # watch mode +``` + +### Build + +Builds a native executable `./agentcore` (gitignored). + +```bash +make build # or: bun run build +make install # /usr/local/bin/agentcore +make uninstall +``` + +### Dev / Run + +```bash +make dev # bun run src/cli.ts +bun run dev # bun run src/index.ts +bun run dev:scheduler # bun run src/scheduler/index.ts +``` + +## Project Layout (high-level) + +- Runtime entrypoints: `src/cli.ts` (bin), `src/index.ts` (Core process) +- Core safety mechanisms: `src/core/**` +- IPC JSONL protocol/transport: `src/ipc/**` +- Daemon entrypoints: `src/daemon/**` +- Worker adapters + process mgmt: `src/worker/**` +- Workflow runner/executor: `src/workflow/**` +- Scheduler layer: `src/scheduler/**` +- Design notes: `docs/**` +- Unit/integration tests: `test/**` +- Acceptance + branch-safety tests: `tests/**` + +## Code Style Guidelines + +### TypeScript / Module System + +- ESM everywhere (`package.json` has `"type": "module"`). +- Relative imports for local modules MUST include the `.js` extension. + - Example: `import { AgentCore } from "./core/agentcore.js";` +- Use `node:` specifiers for built-in modules. + - Example: `import { readFile } from "node:fs/promises";` +- Use `import type { ... }` for type-only imports. +- Avoid the `@/*` path alias unless you confirm runtime resolution; prefer relative imports. + +### Formatting + +- 2-space indentation, semicolons, trailing commas in multiline constructs. +- Keep changes minimal: no repo-wide reformatting sweeps (no formatter is enforced). + +### Imports + +- No enforced ordering; keep import groups consistent within the file you touch. +- Keep `import ...` statements at the top of the file. +- Prefer `../types/index.js` barrel exports for shared types/enums. +- Avoid default exports for local modules; use named exports (existing pattern). + +### Types + +- `tsconfig.json` is strict (`noUncheckedIndexedAccess`, `noUnusedLocals/Parameters`, etc.). +- Avoid `any` in `src/**`. + - Use `unknown` in `catch` and narrow via `instanceof Error`, `typeof`, etc. +- Add explicit return types for exported functions and public class methods. +- Prefer small discriminated unions / string enums for protocol/state machines. + +### Naming + +- Files: `kebab-case.ts` (e.g., `json-lines-transport.ts`). +- Classes/types/interfaces/enums: `PascalCase`. +- Functions/locals: `camelCase`. +- Constants: `SCREAMING_SNAKE_CASE`. +- IDs are UUID strings: use `generateId()` / `crypto.randomUUID()`. + +### Error Handling + +- Throw `Error` (or subclasses), never strings. +- Set `err.name` on custom error classes; attach `cause` when useful. +- When surfacing errors across boundaries (IPC/worker/workflow), include context: `requestId`, `jobId`, `permitId`, `stepId`. +- Use `ErrorClass` for retryability when returning `WorkerResult`-style failures. + +### Cancellation / Timeouts / Cleanup + +- If a function can block (IO, process spawn, long loop), accept an `AbortSignal` + and stop promptly when aborted. +- Always clear timers and remove event listeners in `finally` blocks. +- When spawning subprocesses, prefer `ProcessManager` (abort, timeout, best-effort process-group termination). + +### IPC Safety + +- IPC is JSON Lines over stdout. In server/Core mode, NEVER write non-JSON to stdout (use stderr/loggers). +- Validate message shape before acting (see `src/ipc/protocol.ts`). + +### Tests + +- Use `bun:test` (`describe`, `test`/`it`, `expect`). +- Keep tests deterministic; avoid network and real worker CLIs in unit tests. +- Use temp dirs for filesystem work; clean up after. + +## Cursor / Copilot Rules + +- No `.cursor/rules/`, `.cursorrules`, or `.github/copilot-instructions.md` found in this repo. diff --git a/Makefile b/Makefile index 20e54c6..629279f 100644 --- a/Makefile +++ b/Makefile @@ -1,42 +1,62 @@ -.PHONY: all build test test-unit test-integration test-at test-all typecheck clean help -BIN := agentcore +.PHONY: all build test tests test-unit test-integration test-at test-branch test-all typecheck clean install uninstall dev help + +BIN := roboppi +LEGACY_BIN := agentcore + +# Legacy build artifacts (no longer built by default) +EXTRA_BINS := roboppi-core roboppi-workflow roboppi-daemon + SRC := src/cli.ts +# Allow passing Bun runtime flags (e.g. BUN_FLAGS=--smol) +BUN ?= bun +BUN_FLAGS ?= + +# Extra flags passed to `bun build` (e.g. BUN_BUILD_FLAGS=--compile-exec-argv=--smol) +BUN_BUILD_FLAGS ?= + all: build ## Build the binary (default) -build: ## Build single binary - bun build --compile $(SRC) --outfile $(BIN) +build: ## Build a single binary (roboppi) + $(BUN) $(BUN_FLAGS) build $(BUN_BUILD_FLAGS) --compile $(SRC) --outfile $(BIN) + @ln -sf $(BIN) $(LEGACY_BIN) 2>/dev/null || cp $(BIN) $(LEGACY_BIN) test: ## Run all tests - bun test + $(BUN) $(BUN_FLAGS) test + +tests: test ## Alias for `make test` test-unit: ## Run unit tests only - bun test test/unit + $(BUN) $(BUN_FLAGS) test test/unit test-integration: ## Run integration tests only - bun test test/integration + $(BUN) $(BUN_FLAGS) test test/integration test-at: ## Run acceptance tests only - bun test tests/at + $(BUN) $(BUN_FLAGS) test tests/at + +test-branch: ## Run branch safety verification tests + bash tests/branch/run-branch-verification.sh -test-all: typecheck test ## Run type check + all tests +test-all: typecheck test test-branch ## Run type check + all tests @echo "All checks passed" typecheck: ## Run TypeScript type checker - bun x tsc --noEmit + $(BUN) $(BUN_FLAGS) x tsc --noEmit clean: ## Remove build artifacts - rm -f $(BIN) + rm -f $(BIN) $(LEGACY_BIN) $(EXTRA_BINS) install: build ## Install binary to /usr/local/bin install -m 755 $(BIN) /usr/local/bin/$(BIN) + @ln -sf /usr/local/bin/$(BIN) /usr/local/bin/$(LEGACY_BIN) 2>/dev/null || cp /usr/local/bin/$(BIN) /usr/local/bin/$(LEGACY_BIN) uninstall: ## Remove binary from /usr/local/bin - rm -f /usr/local/bin/$(BIN) + rm -f /usr/local/bin/$(BIN) /usr/local/bin/$(LEGACY_BIN) $(addprefix /usr/local/bin/,$(EXTRA_BINS)) dev: ## Run in dev mode (no build) - bun run $(SRC) + $(BUN) $(BUN_FLAGS) run $(SRC) help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..a1190ff --- /dev/null +++ b/README.ja.md @@ -0,0 +1,296 @@ +# Roboppi + +[English](README.md) | 日本語 + +Roboppi(ろぼっぴ)は、エージェント(worker)の実行を安全に制御するための「実行制御ランタイム」です。 + +Roboppi 自身が「エージェントとして仕事をする」ことは目的にしません。外部の worker CLI(OpenCode / Claude Code / Codex CLI / シェル)に重い作業(編集/コマンド/テスト)を委譲し、Roboppi は安全の不変条件(止める/制限する/観測する/隔離する)を強制します。 + +注意: 主な CLI/バイナリ名は `roboppi` です。 +- 互換: `agentcore` はレガシー alias として残しています。 +- 環境変数/状態ディレクトリ: `ROBOPPI_` / `.roboppi-loop/` を推奨(レガシーの `AGENTCORE_` / `.agentcore-loop/` も引き続き利用可能)。 + +## Roboppiでできること(Capabilities) + +実行制御(Core ランタイム): + +- ハードな予算制約: timeout / max attempts / concurrency / RPS /(任意で)cost cap +- エンドツーエンドのキャンセル: Job -> Permit -> Worker を AbortSignal で伝播(best-effort で SIGTERM -> SIGKILL) +- 失敗の封じ込め: circuit breaker + backpressure(過負荷時の reject/defer/degrade) +- プロセス隔離: heavy work を別プロセスに委譲して blast radius を限定 +- 監査性: 構造化ログ + アーティファクト(stdout/stderr の要約、diff/patch、実行コマンド、時間など) + +ワークフロー(YAML オーケストレーション): + +- 複数ステップの DAG 実行(`depends_on`)と並列度制御(`concurrency`) +- `context/` を使ったファイルベースの成果物受け渡し(`outputs` / `inputs`) +- 失敗ポリシー(`retry` / `continue` / `abort`)と自動リトライ +- `completion_check` + `max_iterations` による「完了するまでループ」 +- (任意)ループ収束ガード: 停滞検知 + スコープ/差分予算(`allowed_paths`, `max_changed_files`) + +自動化(Daemon モード): + +- interval/cron/fswatch/webhook/command のイベントで workflow を常駐実行 +- evaluate/analyze ゲート(条件成立時だけ LLM/worker を走らせる) +- supervised 実行: Supervisor -> Core -> Worker のプロセスツリーを IPC で維持(socket transport あり) + +リポジトリ安全(git workspace): + +- base branch の決定を固定化し、起動時 SHA を記録(追跡/再現性) +- Branch Lock: 実行中の repo/branch ドリフトを step 実行前に fail-fast +- Protected branch guard: `main` などへの直接編集をデフォルトでブロック(明示 override が必要) + +拡張性: + +- `WorkerAdapter` インタフェースで worker を追加 +- agent catalog(`agents.yaml`)で `step.agent` による再利用プロファイル + +スケジューリング(参照実装): + +- 優先度付きジョブキュー(interactive vs batch) +- idempotency key による重複制御(`coalesce` / `latest-wins` / `reject`) +- リトライポリシー(指数バックオフ + jitter)と DLQ +- Core を起動/監視する Supervisor(クラッシュ/ハング対策) + +## アーキテクチャ(概要) + +Roboppi は 3 層構造として設計されています。 + +``` +Supervisor / Runner(policy, 親プロセス) + -> Core(mechanism, 子プロセス) + -> Worker プロセス(外部 CLI) +``` + +- Core が安全の不変条件(permits, budgets, cancellation, cutoffs)を保持します。 +- policy(順序、リトライ、重複制御など)は Supervisor/Runner 側に寄せて差し替え可能にします。 +- Core と Supervisor は JSON Lines IPC で通信します。 + +設計資料: + +- `docs/design.md` +- `docs/guide/architecture.md` + +## インストール + +前提: + +- Bun(CI は Bun 1.3.8) + +任意(委譲したい worker に応じて): + +- OpenCode: `opencode` +- Claude Code: `claude` +- Codex CLI: `codex` +- (デモで PR 作成する場合)`gh` + +依存インストール: + +```bash +bun install +``` + +バイナリをビルド(任意): + +```bash +make build +./roboppi --help +./roboppi workflow --help +./roboppi daemon --help +``` + +## クイックスタート + +### 0) IPC サーバーモード(`roboppi`) + +JSON Lines IPC サーバーを起動します(stdin を読み、stdout に応答。ログは stderr)。 + +```bash +./roboppi +``` + +自前の Supervisor/Scheduler を作る場合、この Core プロセスに対して IPC を話します。 + +### 1) one-shot 実行(`roboppi run`) + +1 回の worker タスクを、timeout/budget 付きで委譲します。 + +```bash +./roboppi run --worker opencode --workspace /tmp/demo \ + --capabilities READ,EDIT --timeout 60000 \ + "Write a README for this repo" +``` + +### 2) workflow YAML を実行する + +workflow runner で実行します。デフォルトは supervised 実行(Core IPC 経由)です。 +Core を介さず runner が直接 worker を spawn する場合は `--direct` を使います。 + +```bash +./roboppi workflow examples/hello-world.yaml --verbose +# (dev) bun run src/workflow/run.ts examples/hello-world.yaml --verbose +``` + +```bash +ROBOPPI_VERBOSE=1 bun run src/workflow/run.ts examples/agent-pr-loop.yaml \ + --workspace /tmp/my-work --verbose +``` + +ドキュメント: + +- `docs/guide/workflow.md` +- `docs/workflow-design.md` + +### 3) daemon モード(常駐実行) + +```bash +./roboppi daemon examples/daemon/agent-pr-loop.yaml --verbose +# (dev) bun run src/daemon/cli.ts examples/daemon/agent-pr-loop.yaml --verbose +``` + +ドキュメント: + +- `docs/guide/daemon.md` + +### 4) Agent PR Loop デモ + +`/tmp` 配下にスクラッチ git repo を作り、次のループを最後まで実行します。 + +`design -> todo -> (implement <-> review/fix)* -> (optional) create_pr` + +```bash +bash examples/agent-pr-loop-demo/run-in-tmp.sh +``` + +デモの PR 作成を有効化するには、対象 repo にマーカーファイルを置きます。 + +```bash +touch "/path/to/target/.roboppi-loop/enable_pr" +``` + +自分の repo に対して実行(推奨: Roboppi 側と編集対象 workspace を分ける): + +```bash +ROBOPPI_ROOT=/path/to/roboppi +TARGET=/path/to/your/repo + +mkdir -p "$TARGET/.roboppi-loop" +$EDITOR "$TARGET/.roboppi-loop/request.md" + +ROBOPPI_ROOT="$ROBOPPI_ROOT" bun run --cwd "$ROBOPPI_ROOT" src/workflow/run.ts \ + "$ROBOPPI_ROOT/examples/agent-pr-loop.yaml" \ + --workspace "$TARGET" --verbose +``` + +注意: + +- workflow は target workspace 内に `.roboppi-loop/` と `context/` を作ります(通常は gitignore 推奨。レガシーの `.agentcore-loop/` も引き続き利用可能)。 +- PR 作成は `.roboppi-loop/enable_pr` による opt-in です。 + +## Workflow YAML の最小例 + +```yaml +name: build-test +version: "1" +timeout: "10m" + +steps: + build: + worker: CUSTOM + instructions: "make build" + capabilities: [RUN_COMMANDS] + + test: + depends_on: [build] + worker: CUSTOM + instructions: "make test" + capabilities: [RUN_TESTS] +``` + +ループさせたい場合: + +- `completion_check` + `max_iterations` を使う +- 判定は stdout 文字列より `decision_file`(ファイルベース)を推奨 + +詳細: `docs/guide/workflow.md` + +## Agent Catalog(再利用できるエージェント定義) + +同じ worker/model/capabilities/base-instructions を繰り返す場合、外部 YAML(agent catalog)にまとめられます。 + +```yaml +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + You are a research agent. + - Only read files. Do not edit. +``` + +workflow から参照: + +```yaml +steps: + investigate: + agent: research + instructions: "Investigate the codebase and write notes." +``` + +ドキュメント: + +- `docs/guides/agents.md` +- `docs/guides/agents.ja.md` + +## ブランチ安全(git workspace) + +git repo に対する workflow 実行向けに、base branch 解決 / Branch Lock / 保護ブランチガードを提供します。 + +便利なフラグ: + +- `--base-branch `(`BASE_BRANCH` を上書き) +- `--protected-branches `(既定: `main,master,release/*`) +- `--allow-protected-branch`(危険: 明示 override) + +ドキュメント: + +- `docs/guides/branch.md` +- `docs/guides/branch.ja.md` + +## Supervised IPC transport(supervised 実行) + +supervised 実行では runner/daemon が Core 子プロセスを起動し、IPC 経由で step を委譲します。 + +- 既定: 対話実行は stdio、非対話実行は socket transport が既定 +- transport 切替: `AGENTCORE_SUPERVISED_IPC_TRANSPORT=stdio|socket|tcp` +- IPC デバッグ: `AGENTCORE_IPC_TRACE=1` +- request timeout 調整: `AGENTCORE_IPC_REQUEST_TIMEOUT=2m`(または `AGENTCORE_IPC_REQUEST_TIMEOUT_MS=120000`) + +supervised 実行を無効化(direct 実行): + +- workflow runner: `--direct` +- daemon CLI: `--direct` + +## ドキュメント + +- `docs/guide/quickstart.md` +- `docs/guide/workflow.md` +- `docs/guide/daemon.md` +- `docs/guide/architecture.md` +- `docs/design.md` +- `docs/guides/agents.md` +- `docs/guides/branch.md` + +## 開発 + +```bash +make typecheck +make test +make test-all +``` + +## ステータス + +このプロジェクトは進化中で、API/挙動(CLI フラグや YAML スキーマ)は今後も改善される可能性があります。一方で設計の核(mechanism/policy 分離 + permits + プロセス隔離)は安定させる方針です。 diff --git a/README.md b/README.md index f46d35e..b83c60e 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,301 @@ # Roboppi -Roboppi (ろぼっぴ, pronounced "roh-boh-pee") is an execution-control runtime for AI agents. +English | [日本語](README.ja.md) -"Roboppi" is a cute, nickname-style Japanese name: "robo" (robot) + a playful diminutive-style ending ("-ppi") that can feel a bit like saying "little" or "mini". The vibe is a small robot sidekick that keeps your automation safe and on track. +Roboppi (ろぼっぴ, pronounced "roh-boh-pee") is an execution-control runtime for agentic workers. -It delegates heavy work (code edits, commands, tests) to external worker CLIs (OpenCode / Claude Code / Codex CLI), while Roboppi focuses on the safety mechanics: +It does not try to be "the agent". It enforces safety invariants around external worker CLIs (OpenCode / Claude Code / Codex CLI / plain shell), so your automation stops when it should, stays within budgets, and leaves a trail you can audit. -- stop (timeouts, cancellation) -- limit (concurrency/RPS/budgets) -- observe (structured events and artifacts) -- isolate (separate processes for workers) +Note: the primary CLI/binary name is `roboppi`. +- Compatibility: `agentcore` remains as a legacy alias. +- Env vars/state dirs: prefer `ROBOPPI_` / `.roboppi-loop/` (legacy `AGENTCORE_` / `.agentcore-loop/` are still accepted). -The goal is to prevent common event-loop automation failures: duplicate runs, infinite retries, cascading failures, and hangs. +## What Roboppi Can Do (Capabilities) -## What You Get +Execution control (Core runtime): -- Hard stop guarantees via budgets (timeouts, max attempts, concurrency) -- End-to-end cancellation (Job -> Permit -> Worker) via AbortSignal/AbortController -- Failure containment via circuit breakers (LLM + workers) -- Better auditability: stdout/stderr/progress/patches as events +- Enforce hard budgets: timeouts, max attempts, concurrency, RPS, optional cost caps +- End-to-end cancellation: Job -> Permit -> Worker via AbortSignal (best-effort SIGTERM -> SIGKILL) +- Failure containment: circuit breakers + backpressure responses under overload +- Process isolation: delegate heavy work to separate worker processes (limit blast radius) +- Auditability: structured logs + artifacts (stdout/stderr summaries, diffs/patches, executed commands, timings) -## Quickstart +Workflow orchestration (YAML): + +- Run multi-step DAG workflows (`depends_on`) with optional parallelism (`concurrency`) +- Pass files between steps with declared `outputs`/`inputs` via a `context/` directory +- Automatic step retries and failure policy (`retry` / `continue` / `abort`) +- Loop until complete with `completion_check` + `max_iterations` +- Optional convergence guard for loops: stall detection + scope/diff budgets (`allowed_paths`, `max_changed_files`) + +Automation (Daemon mode): + +- Run workflows on interval/cron/fswatch/webhook/command events +- Optional evaluate/analyze gates (run LLM work only when conditions are met) +- Supervised execution: Supervisor -> Core -> Worker process tree via IPC (socket-based transport available) + +Repo safety (git workspaces): + +- Deterministic base branch resolution (records base commit SHA) +- Branch Lock drift detection (fail-fast before a step runs) +- Protected-branch guard to avoid direct edits on `main` (override must be explicit) + +Extensibility: + +- Add worker adapters behind a unified `WorkerAdapter` interface +- Reusable agent profiles via agent catalogs (`agents.yaml`) referenced by `step.agent` + +Scheduling (reference implementation): + +- Prioritized job queue (interactive vs batch) +- Deduplication via idempotency keys (`coalesce` / `latest-wins` / `reject`) +- Retry policy (exponential backoff + jitter) + DLQ storage +- Supervisor that launches/monitors the Core process (crash/hang handling) + +## Architecture (High Level) + +Roboppi is designed as a 3-layer system: + +``` +Supervisor / Runner (policy, parent process) + -> Core (mechanism, child process) + -> Worker processes (external CLIs) +``` + +- Core owns safety invariants (permits, budgets, cancellation, cutoffs). +- Policy (ordering, retries, dedup strategy) stays swappable at the supervisor/runner level. +- Core and supervisor communicate over JSON Lines IPC. + +Design docs: + +- `docs/design.md` +- `docs/guide/architecture.md` + +## Install Prerequisites: -- Bun -- Worker CLIs you plan to use: `opencode`, `claude`, `codex` -- Optional (PR creation): `gh` +- Bun (CI uses Bun 1.3.8) + +Optional worker CLIs (depending on what you want to delegate to): -Install: +- OpenCode: `opencode` +- Claude Code: `claude` +- Codex CLI: `codex` +- Optional (PR creation in demos): `gh` + +Install dependencies: ```bash bun install ``` -## Example: Agent PR Loop +Build the binary (optional): -Note: some paths and environment variables in the examples still use the legacy `agentcore` prefix (e.g. `.agentcore-loop/`, `AGENTCORE_ROOT`). +```bash +make build +./roboppi --help +./roboppi workflow --help +./roboppi daemon --help +``` -This is the default multi-worker loop: +## Quickstart -`design -> todo -> (implement <-> review)* -> (optional) create_pr` +### 0) IPC server mode (`roboppi`) -### 1) Run the demo (creates real code in /tmp) +Start a JSON Lines IPC server (reads stdin, writes stdout; logs go to stderr): ```bash -bash examples/agent-pr-loop-demo/run-in-tmp.sh +./roboppi ``` -This creates a scratch repo under `/tmp/` and runs `examples/agent-pr-loop.yaml` end-to-end. +If you are building your own Supervisor/Scheduler, this is the Core process you talk to. -### 2) Run against your own repo +### 1) One-shot worker task (`roboppi run`) + +`roboppi run` delegates a single task to a worker with a strict timeout/budget. ```bash -AGENTCORE_ROOT=/path/to/agentcore -TARGET=/path/to/your/repo +# One-shot: have OpenCode create files +./roboppi run --worker opencode --workspace /tmp/demo \ + --capabilities READ,EDIT --timeout 60000 \ + "Write a README for this repo" +``` -mkdir -p "$TARGET/.agentcore-loop" -$EDITOR "$TARGET/.agentcore-loop/request.md" +### 2) Run a workflow YAML -AGENTCORE_ROOT="$AGENTCORE_ROOT" bun run --cwd "$AGENTCORE_ROOT" ./src/workflow/run.ts \ - "$AGENTCORE_ROOT/examples/agent-pr-loop.yaml" \ - --supervised \ - --workspace "$TARGET" \ - --verbose +Workflows are executed by the workflow runner. + +```bash +./roboppi workflow examples/hello-world.yaml --verbose +# (dev) bun run src/workflow/run.ts examples/hello-world.yaml --verbose ``` -Notes: +By default, workflows run in supervised mode (Supervisor -> Core -> Worker). +Use `--direct` to spawn workers directly from the runner (no Core IPC). + +```bash +ROBOPPI_VERBOSE=1 bun run src/workflow/run.ts examples/agent-pr-loop.yaml \ + --workspace /tmp/my-work --verbose +``` + +Docs: -- Workflow definition: `examples/agent-pr-loop.yaml` -- Helper scripts: `scripts/agent-pr-loop/` -- Loop state/artifacts live under `.agentcore-loop/` (typically gitignored in the target repo) +- `docs/guide/workflow.md` +- `docs/workflow-design.md` -To enable PR creation (disabled by default), create the marker file in the target repo and rerun: +### 3) Run daemon mode (resident workflows) ```bash -touch "$TARGET/.agentcore-loop/enable_pr" +./roboppi daemon examples/daemon/agent-pr-loop.yaml --verbose +# (dev) bun run src/daemon/cli.ts examples/daemon/agent-pr-loop.yaml --verbose ``` -## Daemon Mode (manual kick) +Docs: -Start the daemon: +- `docs/guide/daemon.md` + +### 4) Agent PR Loop demo + +The demo creates a scratch git repo under `/tmp/` and runs the full loop: + +`design -> todo -> (implement <-> review/fix)* -> (optional) create_pr` ```bash -bun run src/daemon/cli.ts examples/daemon/agent-pr-loop.yaml --supervised --verbose +bash examples/agent-pr-loop-demo/run-in-tmp.sh ``` -Kick it from another terminal: +To enable PR creation in the demo, create the marker file in the target repo: ```bash -mkdir -p .agentcore-loop -date +%s > .agentcore-loop/kick.txt +touch "/path/to/target/.roboppi-loop/enable_pr" ``` -## One-shot Worker Task - -Run a single worker task with a budget: +Run the PR loop against your own repo (recommended: run from this repo, target another workspace): ```bash -bun run src/cli.ts run --worker opencode --workspace /tmp/demo \ - --capabilities READ,EDIT --timeout 60000 "Write a README for this repo" +ROBOPPI_ROOT=/path/to/roboppi +TARGET=/path/to/your/repo + +mkdir -p "$TARGET/.roboppi-loop" +$EDITOR "$TARGET/.roboppi-loop/request.md" + +ROBOPPI_ROOT="$ROBOPPI_ROOT" bun run --cwd "$ROBOPPI_ROOT" src/workflow/run.ts \ + "$ROBOPPI_ROOT/examples/agent-pr-loop.yaml" \ + --workspace "$TARGET" --verbose ``` -## Project Layout +Notes: + +- The workflow writes state/artifacts under `.roboppi-loop/` and `context/` inside the target workspace (typically gitignored; legacy `.agentcore-loop/` is still accepted). +- PR creation is opt-in via `.roboppi-loop/enable_pr`. -- Roboppi CLI: `src/cli.ts` (IPC server + one-shot run) -- Workflow runner: `src/workflow/run.ts` -- Multi-worker step runner: `src/workflow/multi-worker-step-runner.ts` -- Design document: `docs/design.md` +## Workflow YAML At A Glance -## Status +Minimal example: + +```yaml +name: build-test +version: "1" +timeout: "10m" + +steps: + build: + worker: CUSTOM + instructions: "make build" + capabilities: [RUN_COMMANDS] + + test: + depends_on: [build] + worker: CUSTOM + instructions: "make test" + capabilities: [RUN_TESTS] +``` + +For loops: + +- use `completion_check` + `max_iterations` +- prefer `decision_file` for deterministic COMPLETE/INCOMPLETE decisions + +See `docs/guide/workflow.md` for the full schema. + +## Agent Catalogs (Reusable Agent Profiles) + +If you repeat the same worker/model/capabilities/base-instructions across steps, define an agent catalog: + +```yaml +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + You are a research agent. + - Only read files. Do not edit. +``` + +Then reference it from workflow steps: -This project is still evolving and APIs/behavior may change. See `docs/design.md` for the underlying design principles. +```yaml +steps: + investigate: + agent: research + instructions: "Investigate the codebase and write notes." +``` + +Docs: + +- `docs/guides/agents.md` +- `docs/guides/agents.ja.md` + +## Branch Safety (Git Workspaces) + +For workflows that operate on git repos, Roboppi provides base-branch resolution, Branch Lock drift detection, and protected-branch guards. + +Useful flags: + +- `--base-branch ` (overrides `BASE_BRANCH`) +- `--protected-branches ` (default: `main,master,release/*`) +- `--allow-protected-branch` (dangerous; explicit override) + +Docs: + +- `docs/guides/branch.md` +- `docs/guides/branch.ja.md` + +## Supervised IPC Transport (For `--supervised`) + +Workflows/daemon run supervised by default. In supervised mode, the runner starts a Core child process and delegates steps over IPC. + +- Default: interactive runs typically use stdio; non-interactive runs default to a socket transport. +- Override transport: `AGENTCORE_SUPERVISED_IPC_TRANSPORT=stdio|socket|tcp` +- Debug IPC: `AGENTCORE_IPC_TRACE=1` +- Tune request timeouts: `AGENTCORE_IPC_REQUEST_TIMEOUT=2m` (or `AGENTCORE_IPC_REQUEST_TIMEOUT_MS=120000`) + +Disable supervised mode: + +- Workflow runner: `--direct` +- Daemon CLI: `--direct` + +## Documentation + +- `docs/guide/quickstart.md` +- `docs/guide/workflow.md` +- `docs/guide/daemon.md` +- `docs/guide/architecture.md` +- `docs/design.md` +- `docs/guides/agents.md` +- `docs/guides/branch.md` ## Development ```bash -bun test -bun run typecheck +make typecheck +make test +make test-all ``` + +## Status + +This project is still evolving and APIs/behavior may change. The design intent is stable (mechanism/policy separation + permits + process isolation); the surface area (CLI flags, YAML schema) may continue to iterate. diff --git a/docs/guide/daemon.md b/docs/guide/daemon.md index d8d1db3..36077f4 100644 --- a/docs/guide/daemon.md +++ b/docs/guide/daemon.md @@ -66,7 +66,8 @@ triggers: Start: ```bash -bun run src/daemon/cli.ts my-daemon.yaml --verbose +roboppi daemon my-daemon.yaml --verbose +# (dev) bun run src/daemon/cli.ts my-daemon.yaml --verbose ``` Output: @@ -95,6 +96,7 @@ version: "1" # schema version (currently fixed to "1") description?: string # optional description workspace: string # working directory (shared by all triggers) +agents_file?: string # optional agent catalog YAML for step.agent resolution log_dir?: string # log output directory (default: ./logs) state_dir?: string # state directory (default: /.daemon-state) max_concurrent_workflows?: number # max concurrent workflows (default: 5) @@ -119,12 +121,24 @@ triggers: # trigger definitions (event -> workflow) | `version` | yes | fixed to `"1"` | | `description` | no | description | | `workspace` | yes | working directory for workflow execution | +| `agents_file` | no | agent catalog YAML used to resolve workflow `step.agent` | | `log_dir` | no | log output directory | | `state_dir` | no | where execution state is persisted | | `max_concurrent_workflows` | no | max workflows running concurrently (default: 5) | | `events` | yes | event sources (at least one) | | `triggers` | yes | triggers (at least one) | +### Agent catalogs for workflows + +If your workflows use `step.agent`, provide an agent catalog to the daemon: + +- `agents_file`: a default catalog path for the daemon (relative paths are resolved from `workspace`) +- `AGENTCORE_AGENTS_FILE`: you can also set this via environment variables (or per-trigger via `triggers..context.env`) as a colon-separated list of YAML paths + +When multiple catalogs are provided, later sources override earlier ones (so a per-trigger `context.env.AGENTCORE_AGENTS_FILE` can override the daemon-wide catalog). + +See also: `docs/guides/agents.md`. + --- ## Event sources @@ -635,14 +649,17 @@ History is stored as `.json` files. Each includes event info, workf ### Start a daemon ```bash -bun run src/daemon/cli.ts [options] +roboppi daemon [options] +# (dev) bun run src/daemon/cli.ts [options] ``` | Option | Description | |-----------|------| | `--workspace`, `-w` | override `workspace` via CLI | | `--verbose`, `-v` | enable verbose logging | -| `--supervised` | run workflows via Core IPC (Supervisor -> Core -> Worker) | +| `--supervised` | supervised mode (default): run workflows via Core IPC (Supervisor -> Core -> Worker) | +| `--direct` | direct mode: spawn worker processes directly (no Core IPC) | +| `--no-supervised` | alias for `--direct` | | `--help`, `-h` | show help | Notes: @@ -653,8 +670,10 @@ Notes: Examples: ```bash -bun run src/daemon/cli.ts my-daemon.yaml -bun run src/daemon/cli.ts my-daemon.yaml --verbose +roboppi daemon my-daemon.yaml +roboppi daemon my-daemon.yaml --verbose +# (dev) bun run src/daemon/cli.ts my-daemon.yaml +# (dev) bun run src/daemon/cli.ts my-daemon.yaml --verbose ``` ### Stop @@ -702,7 +721,8 @@ triggers: Run: ```bash -bun run src/daemon/cli.ts examples/daemon/simple-cron.yaml --verbose +roboppi daemon examples/daemon/simple-cron.yaml --verbose +# (dev) bun run src/daemon/cli.ts examples/daemon/simple-cron.yaml --verbose ``` The health-check workflow (`workflows/health-check.yaml`) is a 1-step workflow that checks disk usage, memory, and load average. diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 36f3ba1..0af5564 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -27,7 +27,7 @@ bun install ## Build ```bash -make # build a single binary -> ./agentcore +make # build a single binary -> ./roboppi (+ ./agentcore alias) ``` Sanity check: @@ -374,28 +374,62 @@ Define multiple steps in YAML and run them in order. ```yaml # my-workflow.yaml name: refactor-and-test +version: "1" +timeout: "30m" + steps: - - name: refactor - worker: opencode + refactor: + worker: OPENCODE workspace: ./src instructions: "Refactor this function" - capabilities: [EDIT] - - - name: test - worker: opencode - workspace: ./src - instructions: "Run the test suite and report the results" - capabilities: [RUN_TESTS] + capabilities: [READ, EDIT] + timeout: "10m" + + test: + worker: CUSTOM + depends_on: [refactor] + instructions: | + bun test + capabilities: [READ, RUN_TESTS] + timeout: "10m" ``` Run: ```bash -./agentcore workflow run my-workflow.yaml +roboppi workflow my-workflow.yaml --verbose +# (dev) bun run src/workflow/run.ts my-workflow.yaml --verbose ``` For each step, AgentCore requests a Permit, delegates to the worker, and collects results. If a step fails, it aborts depending on policy. +### Reuse worker settings with agent catalogs + +If you repeat the same worker + model + base instructions across steps, define an agent catalog and reference it via `agent:`. + +```yaml +# agents.yaml +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + You are a research agent. + Only read files. Do not edit. +``` + +```yaml +# in your workflow +steps: + investigate: + agent: research + instructions: "Investigate the codebase and write notes" +``` + +The workflow runner auto-loads `agents.yaml` next to the workflow YAML, or you can pass `--agents `. + See the [Workflow guide](./workflow.md) for details. --- @@ -406,27 +440,32 @@ In daemon mode, you can automatically run workflows triggered by events (cron, f ```yaml # daemon.yaml -workflows: +name: my-daemon +version: "1" + +workspace: "/tmp/my-daemon" +state_dir: "/tmp/my-daemon/.daemon-state" + +# Optional: default agent catalog used by workflows (for step.agent) +agents_file: "./agents.yaml" + +events: + tick: + type: interval + every: "30s" + +triggers: auto-test: - trigger: - type: fs_watch - paths: ["./src/**/*.ts"] - steps: - - name: run-tests - worker: opencode - workspace: ./ - instructions: "Run tests related to changed files" - capabilities: [RUN_TESTS] - -state_dir: ./.agentcore-state + on: tick + workflow: "./workflows/test.yaml" + on_workflow_failure: ignore ``` Start: ```bash -./agentcore daemon daemon.yaml -# or -bun run src/daemon/cli.ts daemon.yaml --verbose +roboppi daemon daemon.yaml --verbose +# (dev) bun run src/daemon/cli.ts daemon.yaml --verbose ``` When file changes are detected, workflows run automatically. You can also use cron schedules, webhooks, and manual commands as triggers. diff --git a/docs/guide/workflow.md b/docs/guide/workflow.md index a40ffa6..87433d0 100644 --- a/docs/guide/workflow.md +++ b/docs/guide/workflow.md @@ -86,6 +86,7 @@ steps: | Field | Type | Required | Description | |-----------|-----|------|------| | `description` | string | no | step description | +| `agent` | string | no | agent profile id (resolved from an external agent catalog) | | `worker` | enum | yes | worker kind (see below) | | `model` | string | no | model id (format depends on worker/CLI) | | `instructions` | string | yes | instructions passed to the worker | @@ -104,6 +105,57 @@ steps: | `on_iterations_exhausted` | enum | no | behavior on hitting iteration limit: `abort` / `continue` | | `on_failure` | enum | no | behavior on failure: `retry` / `continue` / `abort` (default: `abort`) | +When `agent` is set, the runner can fill defaults from the agent profile. In that case, `worker`, `model`, `capabilities`, `workspace`, and timeout/budget fields can be omitted and will be inherited from the profile (step-level fields override profile defaults). + +If the agent profile defines `base_instructions`, they are prepended before `instructions`. + +--- + +## Agent catalogs (reusable agent profiles) + +To reuse a worker+model+base instructions bundle across workflows, define agents in a separate YAML file and reference them from steps. + +See also: `docs/guides/agents.md`. + +### Agent catalog file format + +Create an `agents.yaml` next to your workflow YAML (or pass an explicit path). + +```yaml +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + You are a research agent. + - Only read files. Do not edit. + - Write findings as Markdown. +``` + +### Reference from a workflow + +```yaml +steps: + investigate: + agent: research + instructions: | + Investigate how workflow execution works in this repo. + Write a short report to docs/research.md. + outputs: + - name: report + path: docs/research.md + type: review +``` + +### How the catalog is loaded + +The runner loads agent catalogs from these sources: + +- Explicit: `AGENTCORE_AGENTS_FILE` (colon-separated list) + `--agents ` (repeatable). If the same agent id is defined multiple times, later definitions override earlier ones (so `--agents` wins over `AGENTCORE_AGENTS_FILE`). +- Implicit (only when no explicit paths are provided): `agents.yaml` / `agents.yml` next to the workflow YAML. + ### capabilities Specify allowed operations as an array. @@ -337,7 +389,7 @@ Step start (iteration 1) | `instructions` | string | yes | check instructions | | `capabilities` | enum[] | yes | checker capabilities (usually `[READ]`) | | `timeout` | DurationString | no | timeout per check | -| `decision_file` | string | no | optional decision file path (workspace-relative). If set, Roboppi reads it after the check runs. Supported formats: structured JSON `{"decision":"complete"|"incomplete","check_id":"...","reasons":[...],"fingerprints":[...]}` (recommended) and legacy `PASS/COMPLETE` / `FAIL/INCOMPLETE` text. When `decision_file` is set, the runner provides `AGENTCORE_COMPLETION_CHECK_ID` in the check environment so the checker can include it as `check_id`. | +| `decision_file` | string | no | optional decision file path (workspace-relative). If set, Roboppi reads it after the check runs. Supported formats: structured JSON `{"decision":"complete"|"incomplete","check_id":"...","reasons":[...],"fingerprints":[...]}` (recommended) and legacy `PASS/COMPLETE` / `FAIL/INCOMPLETE` text. When `decision_file` is set, the runner provides `ROBOPPI_COMPLETION_CHECK_ID` (and legacy `AGENTCORE_COMPLETION_CHECK_ID`) in the check environment so the checker can include it as `check_id`. | ### convergence fields (opt-in) @@ -367,14 +419,14 @@ steps: implement: # ... completion_check: - decision_file: .agentcore-loop/review.verdict + decision_file: .roboppi-loop/review.verdict # ... max_iterations: 10 convergence: enabled: true stall_threshold: 2 max_stage: 3 - diff_base_ref_file: .agentcore-loop/review.base_ref + diff_base_ref_file: .roboppi-loop/review.base_ref allowed_paths: ["src/**", "test/**"] max_changed_files: 200 ``` @@ -511,36 +563,51 @@ These workers require the corresponding tools to be installed (see prerequisites ### Basic ```bash -bun run src/workflow/run.ts +roboppi workflow +# (dev) bun run src/workflow/run.ts ``` +By default, workflows run in **supervised** mode (Supervisor -> Core -> Worker) so all steps go through Core safety mechanisms. +Use `--direct` to spawn worker processes directly from the runner (no Core IPC). + ### Options | Option | Short | Description | Default | |-----------|--------|------|----------| | `--workspace ` | `-w` | working directory | temp directory | | `--verbose` | `-v` | show step output | off | -| `--supervised` | - | delegate steps via Core IPC (Supervisor -> Core -> Worker) | off | +| `--supervised` | - | supervised mode: delegate steps via Core IPC (Supervisor -> Core -> Worker) | on | +| `--direct` | - | direct mode: spawn worker processes directly (no Core IPC) | off | +| `--no-supervised` | - | alias for `--direct` | - | | `--keepalive` | - | emit periodic output to avoid no-output watchdogs | auto (on when non-TTY) | | `--no-keepalive` | - | disable keepalive output | - | | `--keepalive-interval ` | - | keepalive interval (DurationString) | `10s` | | `--ipc-request-timeout ` | - | IPC request timeout for supervised mode (DurationString) | `2m` | +| `--agents ` | - | agent catalog YAML (repeatable); merged with `AGENTCORE_AGENTS_FILE` | auto-load `agents.yaml` / `agents.yml` next to workflow | | `--help` | `-h` | show help | - | ### Examples ```bash # run with a temp directory -bun run src/workflow/run.ts examples/hello-world.yaml +roboppi workflow examples/hello-world.yaml +# (dev) bun run src/workflow/run.ts examples/hello-world.yaml # specify a workspace -bun run src/workflow/run.ts examples/build-test-report.yaml --workspace /tmp/my-work +roboppi workflow examples/build-test-report.yaml --workspace /tmp/my-work +# (dev) bun run src/workflow/run.ts examples/build-test-report.yaml --workspace /tmp/my-work # verbose output -bun run src/workflow/run.ts examples/todo-loop.yaml --verbose +roboppi workflow examples/todo-loop.yaml --verbose +# (dev) bun run src/workflow/run.ts examples/todo-loop.yaml --verbose + +# supervised mode is the default +roboppi workflow examples/agent-pr-loop.yaml --verbose +# (dev) bun run src/workflow/run.ts examples/agent-pr-loop.yaml --verbose -# supervised mode (Core spawns worker processes) -bun run src/workflow/run.ts examples/agent-pr-loop.yaml --supervised --verbose +# direct mode (runner spawns worker processes) +roboppi workflow examples/agent-pr-loop.yaml --direct --verbose +# (dev) bun run src/workflow/run.ts examples/agent-pr-loop.yaml --direct --verbose ``` ### Reading results @@ -610,7 +677,8 @@ steps: Run: ```bash -bun run src/workflow/run.ts examples/hello-world.yaml --verbose +roboppi workflow examples/hello-world.yaml --verbose +# (dev) bun run src/workflow/run.ts examples/hello-world.yaml --verbose ``` Key point: best for learning the basic structure (`name`, `version`, `timeout`, `steps`). diff --git a/docs/guides/agents.ja.md b/docs/guides/agents.ja.md new file mode 100644 index 0000000..19b631a --- /dev/null +++ b/docs/guides/agents.ja.md @@ -0,0 +1,61 @@ +# Agent Catalog(再利用できるエージェント定義) + +ワークフローの各ステップで、同じ設定を何度も書きたくなることがあります。 + +- worker 種別(OPENCODE / CLAUDE_CODE / CODEX_CLI / CUSTOM) +- model +- capabilities(権限) +- 共通の基本指示(base instructions) + +Roboppi では、これらを **ワークフロー外の YAML(Agent Catalog)** に定義し、ワークフロー内では `agent: ` で参照できます。 + +## 1) Agent Catalog の例 + +`agents.yaml` を作成します。 + +```yaml +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + あなたはリサーチ担当です。 + - 読むだけ。編集しない。 +``` + +## 2) ワークフローから参照 + +```yaml +steps: + investigate: + agent: research + instructions: | + コードベースを調査して、短いメモを docs/research.md にまとめてください。 + outputs: + - name: report + path: docs/research.md +``` + +`completion_check` 側でも `agent` を指定できます。 + +## 3) 解決ルール(継承/上書き) + +- `agent` を指定すると、ステップ側で省略した項目(`worker`/`model`/`capabilities`/`workspace`/`timeout`/`max_steps`/`max_command_time` など)を Agent Catalog から補完します。 +- Agent Catalog に `base_instructions` がある場合、それを `instructions` の前に連結します。 +- ステップ側に明示した値が優先(上書き)です。 + +## 4) どこから読み込むか + +Workflow runner: + +- 明示: `AGENTCORE_AGENTS_FILE=/a.yaml:/b.yaml`(コロン区切り)と `--agents `(複数回指定可) +- 暗黙: 明示指定がない場合、workflow YAML と同じディレクトリの `agents.yaml` / `agents.yml` を自動探索 + +Daemon: + +- daemon 設定で `agents_file: "./agents.yaml"` を指定可能(相対パスは `workspace` 基準) +- さらに `AGENTCORE_AGENTS_FILE` を環境変数(または trigger の `context.env`)で指定可能 + +同じ agent id が複数の catalog に定義されている場合、後から読み込んだ定義が上書きされます。 diff --git a/docs/guides/agents.md b/docs/guides/agents.md new file mode 100644 index 0000000..e3062ea --- /dev/null +++ b/docs/guides/agents.md @@ -0,0 +1,68 @@ +# Agent Catalogs (Reusable Agent Profiles) + +Workflows often repeat the same “agent settings” across steps: + +- worker kind (OPENCODE / CLAUDE_CODE / CODEX_CLI / CUSTOM) +- model +- default capabilities +- base instructions (a shared system prompt) + +Roboppi lets you define these once in an external YAML file (an **agent catalog**) and reference them from workflow steps via `agent: `. + +## 1) Agent catalog file + +Create a YAML file like `agents.yaml`: + +```yaml +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + You are a research agent. + Only read files. Do not edit. +``` + +## 2) Reference from a workflow + +```yaml +steps: + investigate: + agent: research + instructions: | + Investigate the codebase and write a short report. + outputs: + - name: report + path: docs/research.md +``` + +`completion_check` can also reference an agent: + +```yaml +completion_check: + agent: research + instructions: "Decide COMPLETE or INCOMPLETE" + decision_file: ".roboppi-loop/decision.json" +``` + +## 3) Resolution rules + +- If `agent` is set, missing fields in the step/check are filled from the agent profile (`worker`, `model`, `capabilities`, `workspace`, `timeout`, `max_steps`, `max_command_time`). +- If the agent defines `base_instructions`, it is prepended before `instructions`. +- Step/check values override profile defaults. + +## 4) How catalogs are loaded + +Workflow runner: + +- Explicit: `ROBOPPI_AGENTS_FILE=/a.yaml:/b.yaml` (colon-separated) and/or `--agents ` (repeatable) (legacy `AGENTCORE_AGENTS_FILE` also supported) +- Implicit: if no explicit paths are provided, auto-load `agents.yaml` / `agents.yml` next to the workflow YAML + +Daemon: + +- Daemon config can set `agents_file: "./agents.yaml"` (relative to `workspace`) +- You can also set `ROBOPPI_AGENTS_FILE` globally or per-trigger via `triggers..context.env` (legacy `AGENTCORE_AGENTS_FILE` also supported) + +When multiple catalogs define the same agent id, later sources override earlier ones. diff --git a/docs/guides/branch.ja.md b/docs/guides/branch.ja.md new file mode 100644 index 0000000..672dbdf --- /dev/null +++ b/docs/guides/branch.ja.md @@ -0,0 +1,297 @@ +# ブランチガイド + +このガイドは、Roboppi が workflow を実行する際の **ブランチ安全仕様**(base branch 解決、Branch Lock、保護ブランチガード)をまとめたものです。 + +Roboppi は git workspace 上で動かすことが多く(例: Agent PR Loop)、 + +- 間違ったブランチで走る +- 実行中に worktree/ブランチが変わる +- work branch を作るつもりが `main` に直接変更してしまう + +といった事故が起こりやすいです。 + +Roboppi は以下でこれを防ぎます。 + +1. **base branch 解決**: `effective_base_branch` を決定し、起動時点の commit SHA も固定して記録する +2. **Branch Lock**: 実行中に repo/branch が想定からずれたら step 実行前に fail-fast する + +加えて、`main` などへの直接変更を防ぐ **保護ブランチガード** を提供します。 + +--- + +## 用語 + +これらは workflow 起動時に `src/workflow/branch-context.ts` で解決され、runner/executor に渡されます。 + +- `startup_toplevel`: 起動時点の `git rev-parse --show-toplevel` +- `startup_branch`: 起動時点の `git rev-parse --abbrev-ref HEAD` +- `startup_head_sha`: 起動時点の `git rev-parse HEAD` +- `effective_base_branch`: 解決された base branch 名 +- `effective_base_branch_source`: `effective_base_branch` の由来(`cli | env | current`) +- `effective_base_sha`: 起動時点の `git rev-parse ^{commit}` +- `expected_work_branch`: workflow が **実際に変更を加えるはずのブランチ** +- `expected_current_branch`: **各 step 実行直前**に runner がいるべきブランチ +- `protected_branches`: 「直接変更を避けたい」ブランチパターン +- `protected_branches_source`: `protected_branches` の由来(`default | env | cli`) +- `allow_protected_branch`: 保護ブランチガードを明示的に無効化する override + +--- + +## Base Branch の解決 + +base branch は起動時に一度だけ決定されます。 + +### 入力 + +- CLI: `--base-branch ` +- env: `BASE_BRANCH=` +- 未指定時: 起動時の現在ブランチ(`startup_branch`) + +### 優先順位 + +1. CLI `--base-branch` +2. env `BASE_BRANCH` +3. `startup_branch`(detached でない場合のみ) + +`startup_branch` が `HEAD`(detached HEAD)の場合、CLI/env でも base branch が指定されていなければ fail-fast します。 + +### 起動時 SHA の固定 + +`effective_base_branch` が決まったら、起動時点の `effective_base_sha` を解決して記録します。 + +### override の warning + +CLI/env で base branch を上書きしており、それが `startup_branch` と異なる場合、runner は warning を出して「今いるブランチ」と「base」が違うことを明示します。 + +--- + +## Workflow YAML のブランチ関連フィールド + +以下は workflow YAML のトップレベルフィールドです(`src/workflow/parser.ts` が検証)。 + +```yaml +create_branch: true|false +branch_transition_step: "branch" # optional +expected_work_branch: "my-branch" # optional +``` + +### `create_branch`(boolean) + +workflow が work branch を作成/切替することを前提にするか。 + +- `false`(既定): 現在ブランチ上で動作 +- `true`: 実行中に work branch へ遷移する前提 + +注意: Roboppi 自体がブランチを作るわけではありません。workflow の step が行う checkout/branch 作成に対して、安全上の期待値を強制します。 + +### `branch_transition_step`(string) + +work branch への遷移が発生する step id。 + +- 省略かつ `create_branch: true` の場合、`branch` という step が存在すれば既定で `branch` になります。 +- この step が成功した直後に、Roboppi は現在のブランチ名を読み取り、 + - `expected_work_branch` + - `expected_current_branch` + + を更新します。 + +### `expected_work_branch`(string) + +起動時点で期待する work branch を明示できます。 + +特定のブランチでしか走ってほしくない workflow(例: `release/v1` 限定の保守作業)で、起動時に間違ったブランチなら即 fail-fast させたい場合に使います。 + +--- + +## Branch Lock(ドリフト検知) + +workspace が git repo の場合、Branch Lock は自動で有効になります。 + +### 何を検証するか + +各 step の **実行前**に executor(`src/workflow/executor.ts`)が以下を検証します。 + +1. **repo/worktree の一致**: 現在の `git rev-parse --show-toplevel` が `startup_toplevel` と一致 +2. **ブランチ一致**: 現在の `git rev-parse --abbrev-ref HEAD` が以下と一致 + - `expected_current_branch`(優先) + - `expected_work_branch` + - `startup_branch` + +不一致なら、その step を実行する前に workflow を失敗させます。 + +### ブランチ遷移 + +`create_branch: true` かつ `branch_transition_step` が設定されている場合、その step の成功直後に現在のブランチ名を読み取り、期待ブランチを更新します。 + +これにより `bootstrap -> branch -> implement -> review` のようなフローで、branch step 以降は確実に work branch 上で実行できます。 + +### git でない workspace + +workspace が git repo でない場合は Branch Lock は無効になり、runner は warning を出します。 + +--- + +## 保護ブランチガード + +保護ブランチガードは、重要ブランチへの直接変更を防ぐための fail-fast です。 + +### 既定値 + +既定の `protected_branches`: + +```text +main, master, release/* +``` + +### パターン仕様 + +- `*` を含まないパターンは完全一致 +- `*` を含む場合は簡易 glob として扱う(特別扱いは `*` のみ)。`*` は `/` を含む任意文字列にマッチします。 + +例: + +- `main` は `main` のみに一致 +- `release/*` は `release/v1`, `release/2026-02` などに一致 + +### ブロック条件 + +起動時に以下すべてを満たす場合、workflow 開始前に Roboppi が停止します。 + +- `create_branch: false` +- `expected_work_branch` が `protected_branches` に一致 +- `allow_protected_branch` が有効でない + +`create_branch: false` は「現在ブランチに直接変更する」前提のため、ここは意図的に厳しくしています。 + +### 設定方法 + +保護ブランチリストの上書き: + +- CLI: `--protected-branches ` +- env: `AGENTCORE_PROTECTED_BRANCHES=` +- 優先順位: CLI > env > default + +ガードの明示無効化(危険): + +- CLI: `--allow-protected-branch` +- env: `AGENTCORE_ALLOW_PROTECTED_BRANCH=1` + +override 有効時は warning を出し、workflow メタデータにも記録します。 + +--- + +## Step に渡される環境変数 + +workflow 実行時、runner は各 step に以下の env を渡します(`src/workflow/run.ts` / `src/daemon/daemon.ts`)。 + +base / startup: + +- `BASE_BRANCH`(`effective_base_branch` に設定) +- `AGENTCORE_EFFECTIVE_BASE_BRANCH` +- `AGENTCORE_EFFECTIVE_BASE_BRANCH_SOURCE`(`cli|env|current`) +- `AGENTCORE_EFFECTIVE_BASE_SHA` +- `AGENTCORE_STARTUP_BRANCH` +- `AGENTCORE_STARTUP_HEAD_SHA` +- `AGENTCORE_STARTUP_TOPLEVEL` + +Branch Lock の期待値: + +- `AGENTCORE_CREATE_BRANCH`(`1` または `0`) +- `AGENTCORE_EXPECTED_WORK_BRANCH` +- `AGENTCORE_EXPECTED_CURRENT_BRANCH` + +保護ブランチガード: + +- `AGENTCORE_PROTECTED_BRANCHES`(CSV) +- `AGENTCORE_ALLOW_PROTECTED_BRANCH`(`1` または `0`) + +--- + +## 可観測性(ログ / 成果物) + +### CLI runner の出力 + +`src/workflow/run.ts` は実行前に Branch Lock のサマリを出力します。 + +- startup branch / SHA / toplevel +- effective base branch / source / SHA +- protected branches / source +- allow override 状態 + +### context 成果物 + +workflow のメタデータは `context/_workflow.json` に書かれます(`src/workflow/context-manager.ts`)。 +ブランチ情報もトップレベルのキーとして含まれます。 + +```json +{ + "branch_lock_enabled": true, + "startup_branch": "main", + "effective_base_branch": "main", + "effective_base_sha": "", + "protected_branches": ["main", "master", "release/*"], + "allow_protected_branch": false +} +``` + +### daemon モード + +daemon モードでは `src/daemon/daemon.ts` が同等の情報をログに出します。 + +--- + +## 例 + +現在ブランチを base として実行(既定): + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo --supervised --verbose +``` + +base branch を明示: + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo \ + --base-branch main --supervised --verbose +``` + +保護ブランチを main/master のみに絞る: + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo \ + --protected-branches main,master --supervised --verbose +``` + +保護ブランチ上での実行を許可(危険): + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo \ + --allow-protected-branch --supervised --verbose +``` + +--- + +## 任意ステップのフラグ切り替え(workflow定義) + +重い処理や環境依存の処理は、`CUSTOM` script 側で sentinel file(有無フラグ)を見て実行可否を切り替える運用ができます。 + +例(Appthrust Platform workflow): +- `.agentcore-loop/enable_live_validation`: 存在する場合のみ live cluster validation step を実行 +- `.agentcore-loop/live-validation.args`: validation wrapper に追加で渡す引数 + +挙動: +- ファイルなし -> script は `0` で終了し、step は skip/pass 扱い +- ファイルあり -> validation コマンドを実行し、失敗時は workflow も失敗 + +重要: +- これらのファイルは workflow/repo 側の約束です。 +- AgentCore 本体が `enable_live_validation` を解釈する機能はありません。step のコマンド実行結果(exit code)だけを評価します。 + +--- + +## 注意点 / ハマりどころ + +- シェルで `BASE_BRANCH` をグローバルに export していると、意図せず既定の「現在ブランチ base」が上書きされます。override が必要なときは `--base-branch` を推奨します。 +- detached HEAD で起動した場合、`--base-branch`(または `BASE_BRANCH`)を指定しないと fail-fast します。 +- ブランチ遷移 step は *named branch* に checkout する必要があります。遷移後に detached HEAD だとエラーになります。 +- 保護ブランチガードは起動時評価です。`branch_transition_step` が保護ブランチへ checkout すると起動時ガードをすり抜け得るため、原則避けてください(必要なら override を明示)。 diff --git a/docs/guides/branch.md b/docs/guides/branch.md new file mode 100644 index 0000000..150afcd --- /dev/null +++ b/docs/guides/branch.md @@ -0,0 +1,293 @@ +# Branch Guide + +This guide describes Roboppi's branch safety rules when running workflows. + +Roboppi is often used against a git workspace (for example, the Agent PR Loop demo). In those workflows, a small mistake such as running from the wrong branch, changing worktrees mid-run, or forgetting to create a work branch can cause accidental edits and confusing diffs. + +Roboppi addresses this with two mechanisms: + +1. **Base branch resolution**: deterministically resolve an `effective_base_branch` and record its commit SHA. +2. **Branch Lock**: fail-fast if the workspace "drifts" away from the expected repo/branch during execution. + +It also adds a **protected branch guard** to prevent direct edits on branches like `main` unless explicitly overridden. + +--- + +## Terminology + +These values are resolved at workflow startup (by `src/workflow/branch-context.ts`) and are used by the workflow runner/executor. + +- `startup_toplevel`: `git rev-parse --show-toplevel` at startup. +- `startup_branch`: `git rev-parse --abbrev-ref HEAD` at startup. +- `startup_head_sha`: `git rev-parse HEAD` at startup. +- `effective_base_branch`: resolved base branch name. +- `effective_base_branch_source`: where `effective_base_branch` came from: `cli | env | current`. +- `effective_base_sha`: `git rev-parse ^{commit}` at startup. +- `expected_work_branch`: the branch the workflow is expected to *modify*. +- `expected_current_branch`: the branch the runner expects to be on *right before each step*. +- `protected_branches`: patterns treated as "do not edit directly". +- `protected_branches_source`: where `protected_branches` came from: `default | env | cli`. +- `allow_protected_branch`: explicit override that disables the protected branch guard. + +--- + +## Base Branch Resolution + +The base branch is resolved exactly once at startup. + +### Inputs + +- CLI: `--base-branch ` +- env: `BASE_BRANCH=` +- fallback: current branch at startup (`startup_branch`) + +### Priority + +1. CLI `--base-branch` +2. env `BASE_BRANCH` +3. `startup_branch` (only if it is not detached) + +If `startup_branch` is `HEAD` (detached HEAD) and neither CLI nor env provides a base branch, the runner fails fast. + +### Recorded commit SHA + +After `effective_base_branch` is selected, Roboppi resolves `effective_base_sha` from it at startup and records it for traceability and reproducibility. + +### Override warning + +If a base branch override is active (CLI/env) and it differs from `startup_branch`, the runner emits a warning so it is obvious that: + +- your current working branch is not the base +- diffs / PR base operations may use a different branch than expected + +--- + +## Workflow YAML Branch Fields + +These fields are top-level workflow YAML fields (validated by `src/workflow/parser.ts`). + +```yaml +create_branch: true|false +branch_transition_step: "branch" # optional +expected_work_branch: "my-branch" # optional +``` + +### `create_branch` (boolean) + +Whether the workflow is expected to create/switch to a work branch. + +- `false` (default): the workflow runs on the current branch. +- `true`: the workflow is expected to transition to a work branch during execution. + +Roboppi does not create branches by itself; it enforces safety expectations around whatever branch-switching your steps perform. + +### `branch_transition_step` (string) + +Step id where the workflow transitions to the work branch. + +- If omitted and `create_branch: true`, Roboppi defaults this to `branch` when a step named `branch` exists. +- After this step completes successfully, Roboppi reads the current branch and updates: + - `expected_work_branch` + - `expected_current_branch` + +### `expected_work_branch` (string) + +Optional explicit expected work branch at startup. + +Use this for workflows that must run on a particular branch (for example, a maintenance workflow that should only run on `release/v1`). If set, Branch Lock will fail-fast before the first step if you are on a different branch. + +--- + +## Branch Lock (Drift Detection) + +Branch Lock is enabled automatically when the workspace is a git repository. + +### What is checked + +Before **every step**, the executor (see `src/workflow/executor.ts`) verifies: + +1. **Worktree/repo match**: current `git rev-parse --show-toplevel` equals `startup_toplevel`. +2. **Branch match**: current `git rev-parse --abbrev-ref HEAD` equals: + - `expected_current_branch` (preferred), else + - `expected_work_branch`, else + - `startup_branch` + +If either check fails, the workflow fails before executing the step. + +### Branch transition + +If `create_branch: true` and a `branch_transition_step` is configured, then after that step succeeds Roboppi updates the expected branch to the currently checked-out named branch. + +This makes workflows like "bootstrap -> branch -> implement -> review" safe: once the branch step checks out the new work branch, later steps are guaranteed to stay on it. + +### Non-git workspaces + +If the workspace is not a git repository, Branch Lock is disabled and the runner logs a warning. + +--- + +## Protected Branch Guard + +Protected branches are a fail-fast safety guard to avoid direct edits on important branches. + +### Defaults + +Default `protected_branches`: + +```text +main, master, release/* +``` + +### Pattern matching + +- A pattern without `*` is an exact match. +- A pattern with `*` is treated as a simple glob (only `*` is special) and matches any characters (including `/`). + +Examples: + +- `main` matches only `main` +- `release/*` matches `release/v1`, `release/2026-02`, etc + +### When it blocks + +At startup, if all of these are true: + +- `create_branch: false` +- `expected_work_branch` matches `protected_branches` +- `allow_protected_branch` is not enabled + +then Roboppi refuses to start the workflow. + +This is intentionally strict because `create_branch: false` implies the workflow will edit on the current branch. + +### How to configure + +Override the protected list: + +- CLI: `--protected-branches ` +- env: `AGENTCORE_PROTECTED_BRANCHES=` +- priority: CLI > env > default + +Disable the guard explicitly (dangerous): + +- CLI: `--allow-protected-branch` +- env: `AGENTCORE_ALLOW_PROTECTED_BRANCH=1` + +When override is enabled, the runner logs a warning and records the override in workflow metadata. + +--- + +## What Roboppi Exports to Steps (Environment) + +When the workflow is executed, Roboppi passes these values to each step as environment variables (see `src/workflow/run.ts` and `src/daemon/daemon.ts`). + +Base / startup: + +- `BASE_BRANCH` (set to `effective_base_branch`) +- `AGENTCORE_EFFECTIVE_BASE_BRANCH` +- `AGENTCORE_EFFECTIVE_BASE_BRANCH_SOURCE` (`cli|env|current`) +- `AGENTCORE_EFFECTIVE_BASE_SHA` +- `AGENTCORE_STARTUP_BRANCH` +- `AGENTCORE_STARTUP_HEAD_SHA` +- `AGENTCORE_STARTUP_TOPLEVEL` + +Branch lock expectations: + +- `AGENTCORE_CREATE_BRANCH` (`1` or `0`) +- `AGENTCORE_EXPECTED_WORK_BRANCH` +- `AGENTCORE_EXPECTED_CURRENT_BRANCH` + +Protected branch guard: + +- `AGENTCORE_PROTECTED_BRANCHES` (CSV) +- `AGENTCORE_ALLOW_PROTECTED_BRANCH` (`1` or `0`) + +--- + +## Observability (Logs and Artifacts) + +### CLI runner output + +`src/workflow/run.ts` prints a Branch Lock summary before running steps, including: + +- startup branch / SHA / toplevel +- effective base branch / source / SHA +- protected branches / source +- allow override state + +### Context artifact + +Workflow metadata is written to `context/_workflow.json` (see `src/workflow/context-manager.ts`). Branch fields are included as top-level keys such as: + +```json +{ + "branch_lock_enabled": true, + "startup_branch": "main", + "effective_base_branch": "main", + "effective_base_sha": "", + "protected_branches": ["main", "master", "release/*"], + "allow_protected_branch": false +} +``` + +### Daemon logs + +In daemon mode, `src/daemon/daemon.ts` logs the resolved context with the same fields. + +--- + +## Examples + +Run a workflow using the current branch as the base (default): + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo --supervised --verbose +``` + +Use a different base branch explicitly: + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo \ + --base-branch main --supervised --verbose +``` + +Restrict protected branches to only main/master: + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo \ + --protected-branches main,master --supervised --verbose +``` + +Allow execution on a protected branch (dangerous): + +```bash +roboppi workflow examples/agent-pr-loop.yaml --workspace /path/to/repo \ + --allow-protected-branch --supervised --verbose +``` + +--- + +## Optional Step Toggles (Workflow-defined) + +Some teams gate expensive or environment-dependent steps behind sentinel files checked by `CUSTOM` scripts. + +Example (Appthrust Platform workflow): +- `.agentcore-loop/enable_live_validation`: if present, run the live-cluster validation step. +- `.agentcore-loop/live-validation.args`: optional extra args passed to the validation wrapper. + +Behavior: +- sentinel file absent -> script exits `0` and the step is treated as skipped/pass. +- sentinel file present -> script runs the validation command and fails the workflow if the command fails. + +Important: +- These files are conventions defined by the workflow/repo. +- AgentCore itself does **not** interpret `enable_live_validation`; it only runs the step command and uses the exit code. + +--- + +## Notes / Gotchas + +- Avoid exporting `BASE_BRANCH` globally in your shell; it can silently override the default "current branch" behavior. Prefer `--base-branch` when you need an override. +- If you start from a detached HEAD, you must pass `--base-branch` (or set `BASE_BRANCH`) so the runner can resolve a meaningful base. +- If your workflow transitions branches, make sure the transition step checks out a *named* branch. A detached HEAD after the transition step is treated as an error. +- The protected branch guard is evaluated at startup. If your `branch_transition_step` checks out a protected branch, it may bypass the startup guard; avoid doing that unless you also enable the explicit override. diff --git a/docs/issues/workflow-domain-modeling.ja.md b/docs/issues/workflow-domain-modeling.ja.md new file mode 100644 index 0000000..9272315 --- /dev/null +++ b/docs/issues/workflow-domain-modeling.ja.md @@ -0,0 +1,165 @@ +# Workflow のドメインモデル化不足(YAMLスキーマ直結 / 正規化・検証の分散) + +ステータス: 提案 + +## 問題 + +Workflow YAML を `parseWorkflow()` で読み取った後の構造が、そのまま実行時のドメインモデルとして使われています。 +その結果、「構成(config)」「実行計画(compiled/resolved)」「実行結果(runtime state)」の境界が薄く、変換・検証が分散しやすい状態です。 + +具体的な症状: + +- supervised / non-supervised の両経路で **同種の正規化処理が重複**している + - 例: `timeout` の duration parse、`workspace` の `path.resolve()`、budget の組み立て + - `src/workflow/multi-worker-step-runner.ts` と `src/workflow/core-ipc-step-runner.ts` に類似ロジックが存在 +- **検証が複数箇所に散在**しており、仕様追加のたびに差分が出やすい + - `src/workflow/parser.ts`(スキーマ/フィールド検証) + - `src/workflow/dag-validator.ts`(DAG整合/一部安全チェック) + - `src/workflow/executor.ts`(実行中の安全/収束/infra failure ガード) +- Core IPC 経由の `WORKER_TASK` が **payload の型契約に弱い** + - Core 側で `job.payload as { ... }` のようなキャストに依存(`src/core/agentcore.ts`) + - Runner/Core 間の「期待フィールド」が型で共有されていないため、破壊的変更が混入しやすい + +## 現行構造(要約) + +- YAML -> `WorkflowDefinition` / `StepDefinition`(`src/workflow/types.ts`) +- runner: 読み込み/パース/validateDag -> `WorkflowExecutor` に投入(`src/workflow/run.ts`) +- executor: DAG状態管理 + loop(completion_check) + 収束ガード等(`src/workflow/executor.ts`) +- step 実行: `StepRunner` ポートに委譲 + - direct spawn: `MultiWorkerStepRunner`(`src/workflow/multi-worker-step-runner.ts`) + - supervised: `CoreIpcStepRunner`(`src/workflow/core-ipc-step-runner.ts`) + +この分割自体は健全ですが、「YAML型」と「実行用の解決済みモデル」が分離されていない点が、重複・分散の根になっています。 + +## 根本原因 + +1) `StepDefinition` が「ユーザー入力(YAML)」と「実行計画(resolved)」の両方を兼ねている + +2) 正規化(duration/path/capabilities/budget/env)を StepRunner 側で都度行っている + +3) IPC payload の型を Core/Runner で共有しておらず、境界が暗黙契約になっている + +## 目標 + +- 構成(YAML)と実行計画(resolved/compiled)を分離し、**正規化・検証を単一パスに集約**する +- supervised / non-supervised で **同一の解決結果(timeout/workspace/budget等)**になることを担保する +- Core IPC の `WORKER_TASK` payload を型で固定し、破壊的変更をビルド時に検出できるようにする +- 既存 YAML と互換を維持する(破壊的変更はしない) + +## 提案 + +### 1) Resolved/Compiled Workflow モデルを導入 + +`WorkflowDefinition`(YAMLスキーマ)から、実行用の「解決済みモデル」を作る。 + +例(概念): + +- `ResolvedWorkflow`: + - `workflowTimeoutMs: number` + - `steps: Record` +- `ResolvedStep`: + - `stepId: string` + - `workerKind: WorkerKind`(`"OPENCODE"`等ではなく実行enum) + - `workspaceRef: string`(絶対パス) + - `timeoutMs: number` + - `budget: { deadlineAt: number; maxSteps?; maxCommandTimeMs? }` + - `capabilities: WorkerCapability[]`(文字列配列ではなく実行enum) + - `model?: string` + - `completionCheck?: ResolvedCheck` + +この compiled モデルを `WorkflowExecutor` と StepRunner が消費し、StepRunner から duration/path 解決ロジックを排除する。 + +### 2) 検証パイプラインを整理 + +検証を「スキーマ検証」と「セマンティック検証」と「実行安全検証」に分け、責務の場所を固定する。 + +- スキーマ検証: `parser.ts` +- セマンティック検証: DAG、depends_on/inputs整合、completion_check + max_iterations 等 +- 実行安全検証: 実行中の drift / convergence / infra failure など(executor) + +少なくとも、**DAG・max_iterations などの静的制約は compiled 生成前に一度だけ**走るようにする。 + +### 3) Core IPC の WORKER_TASK payload 型を共有 + +`CoreIpcStepRunner` が送る `job.payload` と、Core が受ける payload を、同一の型/validator で共有する。 + +例: + +- `src/types/worker-task-payload.ts` を追加 +- `CoreIpcStepRunner` は `WorkerTaskJobPayload` を組み立てる +- `AgentCore.executeWorkerTask` は payload を type guard で検証し、`as { ... }` を避ける + +### 4) 観測性: resolved 値を成果物に残す + +`context/_workflow.json` や `context//_meta.json` に、解決済みの timeout/budget/workspaceRef/model/agent などを(必要最小限で)残す。 +実行後に「実際に何が設定されていたか」を再現できるようにする。 + +## 実装タスク(段階的) + +### Phase 0: 仕様/境界の確定(小さく決める) + +- [ ] resolved モデルの形を確定する + - 推奨: `timeoutMs` を保持し、`deadlineAt = now + timeoutMs` は step 実行直前に決める(長時間ワークフローでも timeout がズレない) +- [ ] completion_check の timeout 既定を決める + - 推奨: `check.timeout` 未指定時は `step.timeoutMs / 4`(docs の既定と整合)。互換リスクがあれば後方互換フラグで段階導入 +- [ ] agent catalog の解決場所を固定する + - 推奨: 現状どおり parse 時に解決(`src/workflow/parser.ts`)し、resolved は「実行のための正規化」に集中 + +### Phase 1: 正規化を 1 箇所に集約(shared resolver 導入) + +- [ ] `src/workflow/resolve-worker-task.ts`(名称は任意)を追加 + - `resolveTaskLike(def, workspaceRoot, env)` を実装 + - duration string -> ms(`timeoutMs`, `maxCommandTimeMs`) + - `workspace` -> `workspaceRef`(`path.resolve`) + - capability string[] -> `WorkerCapability[]` + - `worker` -> `WorkerKind` + - `model` / `instructions` / `env` を反映 + - `buildWorkerTask(resolved, abortSignal)` を実装(`deadlineAt = now + timeoutMs`) +- [ ] 単体テストを追加: `test/unit/workflow/resolve-worker-task.test.ts` + - duration parse / workspace resolve / capability mapping / default timeout + +### Phase 2: StepRunner の重複除去(resolver 利用に置換) + +- [ ] `src/workflow/multi-worker-step-runner.ts` を resolver 方式に置換 + - 既存の `buildWorkerTask`/型定義を削除し、shared resolver を使用 +- [ ] `src/workflow/core-ipc-step-runner.ts` を resolver 方式に置換 + - `ResolvedWorkerTaskDef`/変換ロジックの重複を削除 +- [ ] 両Runnerで「同一 StepDefinition -> 同一 resolved spec」になることのテストを追加 + - 例: resolved だけ比較(`workerTaskId`/`deadlineAt` は除外) + +### Phase 3: Core IPC 境界の型契約を固定(payload 共有 + 検証) + +- [ ] `src/types/worker-task-job-payload.ts`(名称は任意)を追加 + - `WorkerTaskJobPayload` 型 + type guard(最低限: 必須フィールドの存在/型) +- [ ] `src/workflow/core-ipc-step-runner.ts` で payload を `WorkerTaskJobPayload` で組み立てる +- [ ] `src/core/agentcore.ts` で payload を type guard で検証し、`as { ... }` を排除 + - invalid payload は `job_completed(outcome=failed, errorClass=NON_RETRYABLE)` で確実に終端させる +- [ ] テストを追加 + - invalid payload が Core で fail-fast し、ハング/未完了が起きない + +### Phase 4:(任意)Compiled Workflow モデルへ昇格 + +- [ ] `ResolvedWorkflow` / `ResolvedStepStatic` を導入して「静的フィールド」を事前解決 + - 注意: convergence で `instructions` が動的に変わるため、instructions は毎回適用(静的解決から除外) +- [ ] Executor/Runner の境界を整理し、resolved を一度だけ作って共有する + +### Phase 5: 観測性/成果物(resolved 値の永続化) + +- [ ] `context//_meta.json` に resolved 値(timeoutMs/workspaceRef/workerKind/model 等)を必要最小限で記録 +- [ ] docs 更新(この issue と `docs/guide/workflow.md` の該当箇所) + +## 受け入れ条件 + +- duration/path/budget/capabilities の解決ロジックが 1 箇所に集約される +- `MultiWorkerStepRunner` と `CoreIpcStepRunner` から正規化ロジックが消え、compiled モデルを実行するだけになる +- Core 側が typed payload を検証し、Runner/Core 間の契約が明文化される +- 既存 examples が動作し、`make typecheck` / `make test` が通る + +## 参考(現行の該当箇所) + +- YAML parse / schema: `src/workflow/parser.ts`, `src/workflow/types.ts` +- DAG validate: `src/workflow/dag-validator.ts` +- Executor: `src/workflow/executor.ts` +- StepRunner (direct): `src/workflow/multi-worker-step-runner.ts` +- StepRunner (supervised): `src/workflow/core-ipc-step-runner.ts` +- Core worker delegation: `src/core/agentcore.ts` diff --git a/docs/workflow-design.md b/docs/workflow-design.md index a293ebb..bbb4520 100644 --- a/docs/workflow-design.md +++ b/docs/workflow-design.md @@ -49,6 +49,9 @@ steps: : description?: string # step description + # Optional: reference a reusable agent profile (external catalog) + agent?: string # agent profile id + # ---- Worker config ---- worker: enum # CODEX_CLI | CLAUDE_CODE | OPENCODE | CUSTOM workspace?: string # working directory (default: ".") @@ -127,6 +130,7 @@ interface WorkflowDefinition { interface StepDefinition { description?: string; + agent?: string; // optional agent profile id worker: "CODEX_CLI" | "CLAUDE_CODE" | "OPENCODE" | "CUSTOM"; workspace?: string; instructions: string; @@ -164,6 +168,7 @@ interface ConvergenceDef { } interface CompletionCheckDef { + agent?: string; // optional agent profile id worker: "CODEX_CLI" | "CLAUDE_CODE" | "OPENCODE" | "CUSTOM"; instructions: string; capabilities: ("READ" | "EDIT" | "RUN_TESTS" | "RUN_COMMANDS")[]; @@ -188,6 +193,47 @@ type DurationString = string; // e.g. "5m", "30s", "2h" --- +## 2.7 Agent catalogs (reusable agent profiles) + +Many workflows repeat the same worker/model/capabilities/base-instructions across steps (e.g. a dedicated research agent). To avoid duplication, workflows can reference an external agent catalog. + +### Agent catalog file format + +```yaml +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + You are a research agent. + Only read files. Do not edit. +``` + +### Use from a workflow + +Steps (and `completion_check`) can set `agent: ` and omit repeated fields. + +```yaml +steps: + investigate: + agent: research + instructions: | + Investigate the codebase and write a short report. +``` + +### Resolution rules + +- When `agent` is set, missing step fields are filled from the agent profile (e.g. `worker`, `model`, `capabilities`, `workspace`, `timeout`, `max_steps`, `max_command_time`). +- If the agent profile defines `base_instructions`, they are prepended before `instructions`. +- Step-level fields override profile defaults. + +### Loading rules + +- Explicit: load catalogs from `AGENTCORE_AGENTS_FILE` (colon-separated list) and `--agents ` (repeatable). Catalogs are merged in order; later definitions override earlier ones. +- Implicit: if no explicit catalogs are provided, the runner looks for `agents.yaml` / `agents.yml` next to the workflow YAML. + ## 3. Examples ### 3.1 Implement -> review -> fix workflow diff --git a/examples/agent-pr-loop-demo/README.ja.md b/examples/agent-pr-loop-demo/README.ja.md index e96c007..138d0e9 100644 --- a/examples/agent-pr-loop-demo/README.ja.md +++ b/examples/agent-pr-loop-demo/README.ja.md @@ -40,7 +40,7 @@ TARGET=/tmp/roboppi-prloop-bun-linalg \ - `src/`(ライブラリ + `src/cli.ts`) - `test/`(ユニットテスト) - `package.json`, `tsconfig.json`, `bun.lock` -- `.agentcore-loop/`(ループの design/todo/review の成果物) +- `.roboppi-loop/`(ループの design/todo/review の成果物) ## 検証 @@ -58,7 +58,7 @@ bun run src/cli.ts project --basis '[[1,1,0],[0,1,1]]' --b '[3,1,2]' 最後の `create_pr` ステップは、次のマーカーファイルを作成しない限り意図的に無効になっています: ```bash -touch .agentcore-loop/enable_pr +touch .roboppi-loop/enable_pr ``` その後、ワークフローを再実行してください。PR 作成には、`origin` リモートの設定と `gh` が必要です。 diff --git a/examples/agent-pr-loop-demo/README.md b/examples/agent-pr-loop-demo/README.md index 6c2be7a..c245ab8 100644 --- a/examples/agent-pr-loop-demo/README.md +++ b/examples/agent-pr-loop-demo/README.md @@ -40,7 +40,7 @@ In the generated workspace: - `src/` (library + `src/cli.ts`) - `test/` (unit tests) - `package.json`, `tsconfig.json`, `bun.lock` -- `.agentcore-loop/` (design/todo/review artifacts for the loop) +- `.roboppi-loop/` (design/todo/review artifacts for the loop) ## Verify @@ -58,7 +58,7 @@ bun run src/cli.ts project --basis '[[1,1,0],[0,1,1]]' --b '[3,1,2]' The final `create_pr` step is intentionally disabled unless you create the marker file: ```bash -touch .agentcore-loop/enable_pr +touch .roboppi-loop/enable_pr ``` Then rerun the workflow. Note that PR creation requires a configured `origin` remote and `gh`. diff --git a/examples/agent-pr-loop-demo/run-in-tmp.sh b/examples/agent-pr-loop-demo/run-in-tmp.sh index 5994387..afd9501 100755 --- a/examples/agent-pr-loop-demo/run-in-tmp.sh +++ b/examples/agent-pr-loop-demo/run-in-tmp.sh @@ -2,14 +2,14 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -AGENTCORE_ROOT=$(cd -- "${SCRIPT_DIR}/../.." && pwd) +ROBOPPI_ROOT=$(cd -- "${SCRIPT_DIR}/../.." && pwd) -if [[ "${AGENTCORE_IPC_TRACE:-}" == "1" ]]; then - echo "[demo] AGENTCORE_ROOT=${AGENTCORE_ROOT}" >&2 +if [[ "${ROBOPPI_IPC_TRACE:-${AGENTCORE_IPC_TRACE:-}}" == "1" ]]; then + echo "[demo] ROBOPPI_ROOT=${ROBOPPI_ROOT}" >&2 if command -v git >/dev/null 2>&1; then - SHA=$(git -C "${AGENTCORE_ROOT}" rev-parse --short HEAD 2>/dev/null || true) + SHA=$(git -C "${ROBOPPI_ROOT}" rev-parse --short HEAD 2>/dev/null || true) if [[ -n "${SHA}" ]]; then - echo "[demo] AGENTCORE_HEAD=${SHA}" >&2 + echo "[demo] ROBOPPI_HEAD=${SHA}" >&2 fi fi fi @@ -29,7 +29,7 @@ Environment variables: Examples: bash examples/agent-pr-loop-demo/run-in-tmp.sh - TARGET=/tmp/agentcore-prloop-bun-linalg \ + TARGET=/tmp/roboppi-prloop-bun-linalg \ bash examples/agent-pr-loop-demo/run-in-tmp.sh EOF } @@ -52,14 +52,14 @@ need_cmd opencode need_cmd claude if [[ -z "${TARGET}" ]]; then - TARGET=$(mktemp -d /tmp/agentcore-prloop-bun-linalg-XXXXXX) + TARGET=$(mktemp -d /tmp/roboppi-prloop-bun-linalg-XXXXXX) else mkdir -p "${TARGET}" fi echo "Workspace: ${TARGET}" -mkdir -p "${TARGET}/.agentcore-loop" +mkdir -p "${TARGET}/.roboppi-loop" # Initialize as a git repo with an initial commit so branch creation has a base ref. if [[ ! -d "${TARGET}/.git" ]]; then @@ -69,6 +69,7 @@ fi if [[ ! -f "${TARGET}/.gitignore" ]]; then cat > "${TARGET}/.gitignore" <<'EOF' # workflow state +.roboppi-loop/ .agentcore-loop/ context/ .daemon-context/ @@ -83,11 +84,11 @@ if [[ ! -f "${TARGET}/README.md" ]]; then cat > "${TARGET}/README.md" <<'EOF' # bun-linalg -Scratch repo generated by AgentCore's agent PR loop demo. +Scratch repo generated by Roboppi's agent PR loop demo. EOF fi -cp "${AGENTCORE_ROOT}/examples/agent-pr-loop-demo/request.md" "${TARGET}/.agentcore-loop/request.md" +cp "${ROBOPPI_ROOT}/examples/agent-pr-loop-demo/request.md" "${TARGET}/.roboppi-loop/request.md" if ! git -C "${TARGET}" rev-parse --verify HEAD >/dev/null 2>&1; then git -C "${TARGET}" add -A @@ -95,9 +96,8 @@ if ! git -C "${TARGET}" rev-parse --verify HEAD >/dev/null 2>&1; then fi RUNNER_ARGS=( - "${AGENTCORE_ROOT}/src/workflow/run.ts" - "${AGENTCORE_ROOT}/examples/agent-pr-loop.yaml" - --supervised + "${ROBOPPI_ROOT}/src/workflow/run.ts" + "${ROBOPPI_ROOT}/examples/agent-pr-loop.yaml" --workspace "${TARGET}" ) @@ -105,16 +105,16 @@ if [[ "${VERBOSE}" == "1" ]]; then RUNNER_ARGS+=(--verbose) fi -AGENTCORE_ROOT="${AGENTCORE_ROOT}" \ - bun run --cwd "${AGENTCORE_ROOT}" "${RUNNER_ARGS[@]}" +ROBOPPI_ROOT="${ROBOPPI_ROOT}" \ + bun run --cwd "${ROBOPPI_ROOT}" "${RUNNER_ARGS[@]}" echo echo "Verifying generated repo..." -if [[ -f "${TARGET}/.agentcore-loop/review.verdict" ]]; then - V=$(cat "${TARGET}/.agentcore-loop/review.verdict" 2>/dev/null | tr -d '\r\n\t ') - if [[ "${V}" != "PASS" ]]; then - echo "Error: expected review.verdict=PASS, got: ${V:-missing}" >&2 +if [[ -f "${TARGET}/.roboppi-loop/review.verdict" ]]; then + V=$(cat "${TARGET}/.roboppi-loop/review.verdict" 2>/dev/null | tr -d '\r\n\t ') + if [[ "${V}" != "PASS" && "${V}" != *'"decision":"complete"'* ]]; then + echo "Error: expected review.verdict=PASS or decision=complete, got: ${V:-missing}" >&2 exit 1 fi fi diff --git a/examples/agent-pr-loop.yaml b/examples/agent-pr-loop.yaml index 14f70cd..44ee0db 100644 --- a/examples/agent-pr-loop.yaml +++ b/examples/agent-pr-loop.yaml @@ -5,10 +5,10 @@ # - Small orchestration lives in scripts. # # Run on another workspace (any git repo directory): -# AGENTCORE_ROOT=/path/to/agentcore +# ROBOPPI_ROOT=/path/to/roboppi # TARGET=/path/to/your/repo -# AGENTCORE_ROOT="$AGENTCORE_ROOT" bun run --cwd "$AGENTCORE_ROOT" ./src/workflow/run.ts \ -# "$AGENTCORE_ROOT/examples/agent-pr-loop.yaml" --workspace "$TARGET" --supervised --verbose +# ROBOPPI_ROOT="$ROBOPPI_ROOT" bun run --cwd "$ROBOPPI_ROOT" ./src/workflow/run.ts \ +# "$ROBOPPI_ROOT/examples/agent-pr-loop.yaml" --workspace "$TARGET" --verbose name: agent-pr-loop version: "1" @@ -16,13 +16,15 @@ description: "Design+Todo by OpenCode, implement by Claude Code (looped), review # Budget note: full path with max review iterations can exceed 90m. timeout: "300m" concurrency: 1 +create_branch: true +branch_transition_step: "branch" steps: bootstrap: description: "Validate inputs/tools and initialize loop state" worker: CUSTOM instructions: | - bash "${AGENTCORE_ROOT:-.}/scripts/agent-pr-loop/bootstrap.sh" + bash "${ROBOPPI_ROOT:-${AGENTCORE_ROOT:-.}}/scripts/agent-pr-loop/bootstrap.sh" capabilities: [READ, EDIT, RUN_COMMANDS] timeout: "2m" @@ -31,7 +33,7 @@ steps: worker: CUSTOM depends_on: [bootstrap] instructions: | - bash "${AGENTCORE_ROOT:-.}/scripts/agent-pr-loop/branch.sh" + bash "${ROBOPPI_ROOT:-${AGENTCORE_ROOT:-.}}/scripts/agent-pr-loop/branch.sh" capabilities: [READ, RUN_COMMANDS] timeout: "1m" @@ -46,10 +48,10 @@ steps: Task: Produce a design doc ONLY (no implementation). Read: - - .agentcore-loop/request.md + - .roboppi-loop/request.md Write/update: - - .agentcore-loop/design.md + - .roboppi-loop/design.md Design doc requirements: - Problem statement + goals/non-goals @@ -64,7 +66,7 @@ steps: max_retries: 2 outputs: - name: design - path: ".agentcore-loop/design.md" + path: ".roboppi-loop/design.md" type: review todo: @@ -78,11 +80,11 @@ steps: Task: Convert the design into a concrete TODO checklist. Read: - - .agentcore-loop/request.md - - .agentcore-loop/design.md + - .roboppi-loop/request.md + - .roboppi-loop/design.md Write/update: - - .agentcore-loop/todo.md + - .roboppi-loop/todo.md TODO requirements: - Use Markdown checklist items: "- [ ] ..." @@ -92,7 +94,7 @@ steps: - Include at least 1 validation step (manual or automated) - Include rollback/escape hatch notes if risky - If .agentcore-loop/todo.md already exists, improve it to satisfy the requirements. + If .roboppi-loop/todo.md already exists, improve it to satisfy the requirements. capabilities: [READ, EDIT] timeout: "20m" on_failure: retry @@ -101,7 +103,7 @@ steps: completion_check: worker: CUSTOM instructions: | - bash "${AGENTCORE_ROOT:-.}/scripts/agent-pr-loop/todo-check.sh" + bash "${ROBOPPI_ROOT:-${AGENTCORE_ROOT:-.}}/scripts/agent-pr-loop/todo-check.sh" capabilities: [READ, RUN_COMMANDS] timeout: "30s" @@ -109,7 +111,7 @@ steps: on_iterations_exhausted: abort outputs: - name: todo - path: ".agentcore-loop/todo.md" + path: ".roboppi-loop/todo.md" type: code implement: @@ -123,14 +125,14 @@ steps: Use Claude's team feature if available (split into implement/test/review roles internally). Read: - - .agentcore-loop/request.md - - .agentcore-loop/design.md - - .agentcore-loop/todo.md - - .agentcore-loop/review.md (if present) - - .agentcore-loop/fix.md (if present) + - .roboppi-loop/request.md + - .roboppi-loop/design.md + - .roboppi-loop/todo.md + - .roboppi-loop/review.md (if present) + - .roboppi-loop/fix.md (if present) Task: - - If .agentcore-loop/fix.md exists and is non-empty, apply ONLY those fixes (minimal, targeted changes). + - If .roboppi-loop/fix.md exists and is non-empty, apply ONLY those fixes (minimal, targeted changes). - Otherwise, implement the TODO checklist. - Update the TODO checkboxes as you complete items. - Run the repo's tests (or the best available test command) and fix failures. @@ -153,40 +155,40 @@ steps: completion_check: worker: OPENCODE model: "openai/gpt-5.2" - decision_file: ".agentcore-loop/review.verdict" + decision_file: ".roboppi-loop/review.verdict" instructions: | You are OpenCode. Task: - Review the work against the request. - Write/update: - - .agentcore-loop/review.md - - .agentcore-loop/review.verdict (structured JSON; see Output rules) + - .roboppi-loop/review.md + - .roboppi-loop/review.verdict (structured JSON; see Output rules) - If verdict is FAIL, also write/update: - - .agentcore-loop/fix.md (non-empty; concrete, actionable steps; include file paths) + - .roboppi-loop/fix.md (non-empty; concrete, actionable steps; include file paths) - If verdict is PASS: - - Clear .agentcore-loop/fix.md if it exists (truncate to empty) so stale FAIL instructions cannot leak into future runs. + - Clear .roboppi-loop/fix.md if it exists (truncate to empty) so stale FAIL instructions cannot leak into future runs. Before reviewing, generate these review inputs (workspace is a git repo): - - bash "${AGENTCORE_ROOT:-.}/scripts/agent-pr-loop/review-inputs.sh" + - bash "${ROBOPPI_ROOT:-${AGENTCORE_ROOT:-.}}/scripts/agent-pr-loop/review-inputs.sh" You must read at least: - - .agentcore-loop/request.md - - .agentcore-loop/design.md (if present) - - .agentcore-loop/todo.md (if present) - - .agentcore-loop/review.base_ref - - .agentcore-loop/review.diff - - .agentcore-loop/review.status - - .agentcore-loop/review.untracked - - .agentcore-loop/review.untracked.diff - - Format for .agentcore-loop/review.md: + - .roboppi-loop/request.md + - .roboppi-loop/design.md (if present) + - .roboppi-loop/todo.md (if present) + - .roboppi-loop/review.base_ref + - .roboppi-loop/review.diff + - .roboppi-loop/review.status + - .roboppi-loop/review.untracked + - .roboppi-loop/review.untracked.diff + + Format for .roboppi-loop/review.md: - Sections: Summary, Strengths, Issues, Verification Output rules: - - Write .agentcore-loop/review.verdict as a single-line JSON object: - - PASS => {"decision":"complete","check_id":"$AGENTCORE_COMPLETION_CHECK_ID"} - - FAIL => {"decision":"incomplete","check_id":"$AGENTCORE_COMPLETION_CHECK_ID"} + - Write .roboppi-loop/review.verdict as a single-line JSON object: + - PASS => {"decision":"complete","check_id":"$ROBOPPI_COMPLETION_CHECK_ID"} + - FAIL => {"decision":"incomplete","check_id":"$ROBOPPI_COMPLETION_CHECK_ID"} - When FAIL, also include: - "reasons": ["..."] (human-readable, short) - "fingerprints": ["..."] (stable IDs; used by the Convergence Controller) @@ -195,7 +197,7 @@ steps: - Prefer file/line/test identifiers (e.g. "test:pkg/foo TestBar", "lint:src/x.ts:42", "scope:charts/..."). - Keep each item short. - Fallback: if you cannot emit JSON, write legacy text "PASS" or "FAIL" only (no extra words). - - If verdict is PASS, clear .agentcore-loop/fix.md if it exists (truncate to empty). + - If verdict is PASS, clear .roboppi-loop/fix.md if it exists (truncate to empty). capabilities: [READ, EDIT, RUN_COMMANDS] timeout: "15m" @@ -203,24 +205,24 @@ steps: on_iterations_exhausted: abort outputs: - name: review - path: ".agentcore-loop/review.md" + path: ".roboppi-loop/review.md" type: review - name: verdict - path: ".agentcore-loop/review.verdict" + path: ".roboppi-loop/review.verdict" type: text - name: fix - path: ".agentcore-loop/fix.md" + path: ".roboppi-loop/fix.md" type: code create_pr: - description: "Create a PR (optional; requires .agentcore-loop/enable_pr)" + description: "Create a PR (optional; requires .roboppi-loop/enable_pr)" worker: CUSTOM depends_on: [implement] instructions: | - bash "${AGENTCORE_ROOT:-.}/scripts/agent-pr-loop/create-pr.sh" + bash "${ROBOPPI_ROOT:-${AGENTCORE_ROOT:-.}}/scripts/agent-pr-loop/create-pr.sh" capabilities: [READ, EDIT, RUN_COMMANDS] timeout: "10m" outputs: - name: pr - path: ".agentcore-loop/pr-url.txt" + path: ".roboppi-loop/pr-url.txt" type: text diff --git a/examples/daemon/agent-pr-loop.yaml b/examples/daemon/agent-pr-loop.yaml index f528ead..34f9697 100644 --- a/examples/daemon/agent-pr-loop.yaml +++ b/examples/daemon/agent-pr-loop.yaml @@ -1,16 +1,16 @@ # Agent PR Loop Daemon — manual kick + workflow loop # # Start (from repo root): -# bun run src/daemon/cli.ts examples/daemon/agent-pr-loop.yaml --supervised --verbose +# bun run src/daemon/cli.ts examples/daemon/agent-pr-loop.yaml --verbose # # Start (from any directory): -# AGENTCORE_ROOT=/path/to/agentcore \ -# bun run --cwd "$AGENTCORE_ROOT" src/daemon/cli.ts examples/daemon/agent-pr-loop.yaml \ -# --workspace "$AGENTCORE_ROOT" --supervised --verbose +# ROBOPPI_ROOT=/path/to/roboppi \ +# bun run --cwd "$ROBOPPI_ROOT" src/daemon/cli.ts examples/daemon/agent-pr-loop.yaml \ +# --workspace "$ROBOPPI_ROOT" --verbose # # Kick it (in another terminal): -# mkdir -p .agentcore-loop -# date +%s > .agentcore-loop/kick.txt +# mkdir -p .roboppi-loop +# date +%s > .roboppi-loop/kick.txt name: agent-pr-loop-daemon version: "1" @@ -18,13 +18,13 @@ description: "Manual kick daemon that runs the agent-pr-loop workflow" # Keep workflow YAML resolvable relative to this repo workspace: "." -state_dir: "/tmp/agentcore-daemon-agent-pr-loop/.daemon-state" +state_dir: "/tmp/roboppi-daemon-agent-pr-loop/.daemon-state" max_concurrent_workflows: 1 events: kick: type: command - command: "cat .agentcore-loop/kick.txt 2>/dev/null || echo 0" + command: "cat .roboppi-loop/kick.txt 2>/dev/null || echo 0" interval: "2s" trigger_on: change @@ -41,13 +41,11 @@ triggers: worker: CUSTOM instructions: | set -euo pipefail - echo "${AGENTCORE_EVENT:-}" | grep -q '"changed":true' + echo "${ROBOPPI_EVENT:-}" | grep -q '"changed":true' capabilities: [READ, RUN_COMMANDS] timeout: "5s" context: - env: - BASE_BRANCH: "main" last_result: true event_payload: true @@ -55,16 +53,16 @@ triggers: worker: CUSTOM instructions: | set -e - echo "=== Agent PR Loop (last run) ===" > .agentcore-loop/daemon-summary.txt - echo "Status: $AGENTCORE_WORKFLOW_STATUS" >> .agentcore-loop/daemon-summary.txt - echo "Trigger: $AGENTCORE_TRIGGER_ID" >> .agentcore-loop/daemon-summary.txt - echo "At: $AGENTCORE_TIMESTAMP" >> .agentcore-loop/daemon-summary.txt - if [ -f .agentcore-loop/pr-url.txt ]; then - echo "PR: $(cat .agentcore-loop/pr-url.txt)" >> .agentcore-loop/daemon-summary.txt + echo "=== Agent PR Loop (last run) ===" > .roboppi-loop/daemon-summary.txt + echo "Status: $ROBOPPI_WORKFLOW_STATUS" >> .roboppi-loop/daemon-summary.txt + echo "Trigger: $ROBOPPI_TRIGGER_ID" >> .roboppi-loop/daemon-summary.txt + echo "At: $ROBOPPI_TIMESTAMP" >> .roboppi-loop/daemon-summary.txt + if [ -f .roboppi-loop/pr-url.txt ]; then + echo "PR: $(cat .roboppi-loop/pr-url.txt)" >> .roboppi-loop/daemon-summary.txt fi - cat .agentcore-loop/daemon-summary.txt + cat .roboppi-loop/daemon-summary.txt capabilities: [READ, EDIT, RUN_COMMANDS] timeout: "30s" outputs: - name: summary - path: ".agentcore-loop/daemon-summary.txt" + path: ".roboppi-loop/daemon-summary.txt" diff --git a/package.json b/package.json index 017c792..49ebeb5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { - "name": "agentcore", + "name": "roboppi", "version": "0.1.0", "type": "module", "bin": { + "roboppi": "src/cli.ts", "agentcore": "src/cli.ts" }, "scripts": { @@ -14,7 +15,7 @@ "test:integration": "bun test test/integration", "test:at": "bun test tests/at", "typecheck": "bun x tsc --noEmit", - "build": "bun build --compile src/cli.ts --outfile agentcore" + "build": "make build" }, "devDependencies": { "@types/bun": "latest", diff --git a/scripts/agent-pr-loop/bootstrap.sh b/scripts/agent-pr-loop/bootstrap.sh index 0c77517..5082b37 100755 --- a/scripts/agent-pr-loop/bootstrap.sh +++ b/scripts/agent-pr-loop/bootstrap.sh @@ -6,24 +6,38 @@ if ! git rev-parse --git-dir > /dev/null 2>&1; then exit 1 fi -mkdir -p .agentcore-loop +LOOP_DIR=.roboppi-loop +LEGACY_DIR=.agentcore-loop + +mkdir -p "${LOOP_DIR}" + +# Best-effort migration from legacy loop directory. +# If the legacy dir exists, copy user-provided inputs into the new dir so the +# updated workflow can run without manual moves. +if [ -d "${LEGACY_DIR}" ]; then + for f in request.md base-branch.txt base-sha.txt branch.txt enable_pr; do + if [ ! -f "${LOOP_DIR}/${f}" ] && [ -f "${LEGACY_DIR}/${f}" ]; then + cp "${LEGACY_DIR}/${f}" "${LOOP_DIR}/${f}" + fi + done +fi # Clear derived artifacts from prior runs (keep request + branch/base settings). rm -f \ - .agentcore-loop/review.md \ - .agentcore-loop/review.verdict \ - .agentcore-loop/fix.md \ - .agentcore-loop/review.base_ref \ - .agentcore-loop/review.diff \ - .agentcore-loop/review.status \ - .agentcore-loop/review.untracked \ - .agentcore-loop/review.untracked.diff \ - .agentcore-loop/pr-url.txt \ - .agentcore-loop/pr-body.md \ - .agentcore-loop/pr.txt - -if [ ! -f .agentcore-loop/request.md ]; then - cat > .agentcore-loop/request.md <<'EOF' + "${LOOP_DIR}/review.md" \ + "${LOOP_DIR}/review.verdict" \ + "${LOOP_DIR}/fix.md" \ + "${LOOP_DIR}/review.base_ref" \ + "${LOOP_DIR}/review.diff" \ + "${LOOP_DIR}/review.status" \ + "${LOOP_DIR}/review.untracked" \ + "${LOOP_DIR}/review.untracked.diff" \ + "${LOOP_DIR}/pr-url.txt" \ + "${LOOP_DIR}/pr-body.md" \ + "${LOOP_DIR}/pr.txt" + +if [ ! -f "${LOOP_DIR}/request.md" ]; then + cat > "${LOOP_DIR}/request.md" <<'EOF' # Request (Write what you want to build/change here.) @@ -37,16 +51,30 @@ if [ ! -f .agentcore-loop/request.md ]; then EOF fi -REQ_BYTES=$(wc -c < .agentcore-loop/request.md | tr -d ' ') +REQ_BYTES=$(wc -c < "${LOOP_DIR}/request.md" | tr -d ' ') if [ "${REQ_BYTES}" -lt 120 ]; then - echo "Error: .agentcore-loop/request.md is too small. Please fill it in first." >&2 + echo "Error: ${LOOP_DIR}/request.md is too small. Please fill it in first." >&2 + exit 1 +fi + +if [ -n "${BASE_BRANCH:-}" ]; then + BASE_BRANCH="${BASE_BRANCH}" +else + BASE_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" +fi + +if [ "${BASE_BRANCH}" = "HEAD" ]; then + echo "Error: detached HEAD detected. Set BASE_BRANCH or checkout a branch." >&2 exit 1 fi -BASE_BRANCH="${BASE_BRANCH:-main}" -echo "${BASE_BRANCH}" > .agentcore-loop/base-branch.txt +if [ -d "${LEGACY_DIR}" ]; then + echo "${BASE_BRANCH}" > "${LEGACY_DIR}/base-branch.txt" +fi +echo "${BASE_BRANCH}" > "${LOOP_DIR}/base-branch.txt" +git rev-parse "${BASE_BRANCH}^{commit}" > "${LOOP_DIR}/base-sha.txt" 2>/dev/null || true command -v opencode > /dev/null 2>&1 || { echo "Error: opencode not found in PATH" >&2; exit 1; } command -v claude > /dev/null 2>&1 || { echo "Error: claude (Claude Code) not found in PATH" >&2; exit 1; } -echo "OK" > .agentcore-loop/bootstrap.ok +echo "OK" > "${LOOP_DIR}/bootstrap.ok" diff --git a/scripts/agent-pr-loop/branch.sh b/scripts/agent-pr-loop/branch.sh index 1b78359..f9980ad 100755 --- a/scripts/agent-pr-loop/branch.sh +++ b/scripts/agent-pr-loop/branch.sh @@ -1,14 +1,30 @@ #!/usr/bin/env bash set -euo pipefail -mkdir -p .agentcore-loop -BASE_BRANCH=$(cat .agentcore-loop/base-branch.txt 2>/dev/null || echo main) -BRANCH_FILE=.agentcore-loop/branch.txt +LOOP_DIR=.roboppi-loop +LEGACY_DIR=.agentcore-loop + +mkdir -p "${LOOP_DIR}" + +BASE_BRANCH_FILE="${LOOP_DIR}/base-branch.txt" +if [ ! -f "${BASE_BRANCH_FILE}" ] && [ -f "${LEGACY_DIR}/base-branch.txt" ]; then + BASE_BRANCH_FILE="${LEGACY_DIR}/base-branch.txt" +fi + +BASE_BRANCH=$( + cat "${BASE_BRANCH_FILE}" 2>/dev/null \ + || echo "${BASE_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)}" +) + +BRANCH_FILE="${LOOP_DIR}/branch.txt" +if [ ! -f "${BRANCH_FILE}" ] && [ -f "${LEGACY_DIR}/branch.txt" ]; then + cp "${LEGACY_DIR}/branch.txt" "${BRANCH_FILE}" +fi if [ -f "${BRANCH_FILE}" ] && [ -n "$(tr -d ' ' < "${BRANCH_FILE}")" ]; then BRANCH=$(cat "${BRANCH_FILE}") else - BRANCH="agentcore/loop-$(date +%Y%m%d-%H%M%S)" + BRANCH="roboppi/loop-$(date +%Y%m%d-%H%M%S)" echo "${BRANCH}" > "${BRANCH_FILE}" fi diff --git a/scripts/agent-pr-loop/create-pr.sh b/scripts/agent-pr-loop/create-pr.sh index 073d6f0..10eddd0 100755 --- a/scripts/agent-pr-loop/create-pr.sh +++ b/scripts/agent-pr-loop/create-pr.sh @@ -1,28 +1,48 @@ #!/usr/bin/env bash set -euo pipefail -mkdir -p .agentcore-loop +LOOP_DIR=.roboppi-loop +LEGACY_DIR=.agentcore-loop -if [ ! -f .agentcore-loop/enable_pr ]; then - cat > .agentcore-loop/pr.txt <<'EOF' +mkdir -p "${LOOP_DIR}" + +if [ ! -f "${LOOP_DIR}/request.md" ] && [ -f "${LEGACY_DIR}/request.md" ]; then + cp "${LEGACY_DIR}/request.md" "${LOOP_DIR}/request.md" +fi +if [ ! -f "${LOOP_DIR}/base-branch.txt" ] && [ -f "${LEGACY_DIR}/base-branch.txt" ]; then + cp "${LEGACY_DIR}/base-branch.txt" "${LOOP_DIR}/base-branch.txt" +fi +if [ ! -f "${LOOP_DIR}/branch.txt" ] && [ -f "${LEGACY_DIR}/branch.txt" ]; then + cp "${LEGACY_DIR}/branch.txt" "${LOOP_DIR}/branch.txt" +fi + +if [ ! -f "${LOOP_DIR}/enable_pr" ] && [ -f "${LEGACY_DIR}/enable_pr" ]; then + cp "${LEGACY_DIR}/enable_pr" "${LOOP_DIR}/enable_pr" +fi + +if [ ! -f "${LOOP_DIR}/enable_pr" ]; then + cat > "${LOOP_DIR}/pr.txt" < /dev/null 2>&1 || { echo "Error: gh not found in PATH" >&2; exit 1; } -BASE_BRANCH=$(cat .agentcore-loop/base-branch.txt 2>/dev/null || echo main) -BRANCH=$(cat .agentcore-loop/branch.txt) +BASE_BRANCH=$( + cat "${LOOP_DIR}/base-branch.txt" 2>/dev/null \ + || echo "${BASE_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)}" +) +BRANCH=$(cat "${LOOP_DIR}/branch.txt") git add -A if ! git diff --cached --quiet; then - TITLE=$(head -n 1 .agentcore-loop/request.md | sed -E 's/^#\s*//') + TITLE=$(head -n 1 "${LOOP_DIR}/request.md" | sed -E 's/^#\s*//') [ -n "${TITLE}" ] || TITLE="agent loop changes" git commit -m "${TITLE}" fi @@ -34,7 +54,7 @@ fi git push -u origin "${BRANCH}" -cat > .agentcore-loop/pr-body.md <<'EOF' +cat > "${LOOP_DIR}/pr-body.md" < { const workspaceDir = process.cwd(); - const loopDir = path.join(workspaceDir, ".agentcore-loop"); + const loopDir = path.join(workspaceDir, ".roboppi-loop"); + const legacyLoopDir = path.join(workspaceDir, ".agentcore-loop"); await mkdir(loopDir, { recursive: true }); + // Best-effort migration from legacy loop directory. + for (const f of ["request.md", "design.md", "todo.md", "base-branch.txt"] as const) { + const dst = path.join(loopDir, f); + const src = path.join(legacyLoopDir, f); + if (!(await exists(dst)) && (await exists(src))) { + await copyFile(src, dst); + } + } + const verbose = + process.env.ROBOPPI_VERBOSE === "1" || process.env.AGENTCORE_VERBOSE === "1" || process.env.VERBOSE === "1" || process.env.DEBUG === "1"; @@ -88,20 +99,20 @@ async function main(): Promise { Task: - Review the work against the request. - Use apply_patch to create/overwrite these files: - - .agentcore-loop/review.md - - .agentcore-loop/review.verdict (PASS or FAIL only) + - .roboppi-loop/review.md + - .roboppi-loop/review.verdict (PASS or FAIL only) - If verdict is FAIL, also create/overwrite: - - .agentcore-loop/fix.md (non-empty; concrete, actionable steps; include file paths) + - .roboppi-loop/fix.md (non-empty; concrete, actionable steps; include file paths) You must read at least: -- .agentcore-loop/request.md -- .agentcore-loop/design.md (if present) -- .agentcore-loop/todo.md (if present) -- .agentcore-loop/review.diff -- .agentcore-loop/review.status -- .agentcore-loop/review.untracked - -Format for .agentcore-loop/review.md: +- .roboppi-loop/request.md +- .roboppi-loop/design.md (if present) +- .roboppi-loop/todo.md (if present) +- .roboppi-loop/review.diff +- .roboppi-loop/review.status +- .roboppi-loop/review.untracked + +Format for .roboppi-loop/review.md: - Sections: Summary, Strengths, Issues, Verification `, capabilities: ["READ", "EDIT"], @@ -132,16 +143,16 @@ Format for .agentcore-loop/review.md: worker: "OPENCODE", model: "openai/gpt-5.2", instructions: `Use apply_patch to write: -- .agentcore-loop/review.md -- .agentcore-loop/review.verdict (PASS or FAIL) +- .roboppi-loop/review.md +- .roboppi-loop/review.verdict (PASS or FAIL) If verdict is FAIL, also write: -- .agentcore-loop/fix.md (non-empty) +- .roboppi-loop/fix.md (non-empty) Base your decision on: -- .agentcore-loop/request.md -- .agentcore-loop/artifacts/problem.md -- .agentcore-loop/artifacts/solution.md +- .roboppi-loop/request.md +- .roboppi-loop/artifacts/problem.md +- .roboppi-loop/artifacts/solution.md `, capabilities: ["READ", "EDIT"], timeout: "10m", @@ -180,18 +191,18 @@ Base your decision on: The previous review produced verdict FAIL but did not provide fix instructions. Task: -- Write .agentcore-loop/fix.md with concrete, actionable instructions to make the implementation pass. +- Write .roboppi-loop/fix.md with concrete, actionable instructions to make the implementation pass. Read: -- .agentcore-loop/request.md -- .agentcore-loop/design.md -- .agentcore-loop/todo.md -- .agentcore-loop/review.diff -- .agentcore-loop/review.status -- .agentcore-loop/review.untracked +- .roboppi-loop/request.md +- .roboppi-loop/design.md +- .roboppi-loop/todo.md +- .roboppi-loop/review.diff +- .roboppi-loop/review.status +- .roboppi-loop/review.untracked Write: -- .agentcore-loop/fix.md +- .roboppi-loop/fix.md Rules: - Include file paths. @@ -215,7 +226,7 @@ Rules: const fixBytes2 = (await readText(fixPath)).trim().length; if (fixBytes2 === 0) { - throw new Error("verdict was FAIL but .agentcore-loop/fix.md is still empty"); + throw new Error("verdict was FAIL but .roboppi-loop/fix.md is still empty"); } const fixStep: StepDefinition = { @@ -226,8 +237,8 @@ Rules: Task: Apply the requested fixes. Read: -- .agentcore-loop/fix.md -- .agentcore-loop/todo.md +- .roboppi-loop/fix.md +- .roboppi-loop/todo.md Do: - Make the minimal changes required to address the issues. @@ -257,7 +268,7 @@ Do: .trim(); if (obs) { await writeFile(path.join(loopDir, "fix.last-output.txt"), obs + "\n"); - console.log("[review-fix-once] fix: wrote .agentcore-loop/fix.last-output.txt"); + console.log("[review-fix-once] fix: wrote .roboppi-loop/fix.last-output.txt"); } throw new Error("fix step failed"); } diff --git a/scripts/agent-pr-loop/review-inputs.sh b/scripts/agent-pr-loop/review-inputs.sh index 905cde1..51bd467 100755 --- a/scripts/agent-pr-loop/review-inputs.sh +++ b/scripts/agent-pr-loop/review-inputs.sh @@ -4,15 +4,25 @@ set -euo pipefail # Generate deterministic review inputs for the agent PR loop. # # Outputs: -# .agentcore-loop/review.base_ref - resolved base ref used for diffs -# .agentcore-loop/review.diff - git diff against base ref (tracked changes) -# .agentcore-loop/review.status - git status --porcelain -# .agentcore-loop/review.untracked - list of untracked (non-ignored) files -# .agentcore-loop/review.untracked.diff - diffs for untracked files (as /dev/null -> file) +# .roboppi-loop/review.base_ref - resolved base ref used for diffs +# .roboppi-loop/review.diff - git diff against base ref (tracked changes) +# .roboppi-loop/review.status - git status --porcelain +# .roboppi-loop/review.untracked - list of untracked (non-ignored) files +# .roboppi-loop/review.untracked.diff - diffs for untracked files (as /dev/null -> file) -mkdir -p .agentcore-loop +LOOP_DIR=.roboppi-loop +LEGACY_DIR=.agentcore-loop -BASE_BRANCH=$(cat .agentcore-loop/base-branch.txt 2>/dev/null || echo main) +mkdir -p "${LOOP_DIR}" + +if [ ! -f "${LOOP_DIR}/base-branch.txt" ] && [ -f "${LEGACY_DIR}/base-branch.txt" ]; then + cp "${LEGACY_DIR}/base-branch.txt" "${LOOP_DIR}/base-branch.txt" +fi + +BASE_BRANCH=$( + cat "${LOOP_DIR}/base-branch.txt" 2>/dev/null \ + || echo "${BASE_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)}" +) git fetch origin "${BASE_BRANCH}" >/dev/null 2>&1 || true if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then @@ -23,18 +33,18 @@ else BASE_REF="HEAD" fi -echo "${BASE_REF}" > .agentcore-loop/review.base_ref +echo "${BASE_REF}" > "${LOOP_DIR}/review.base_ref" -git diff --no-color "${BASE_REF}" > .agentcore-loop/review.diff || true -git status --porcelain=v1 > .agentcore-loop/review.status || true -git ls-files --others --exclude-standard > .agentcore-loop/review.untracked || true +git diff --no-color "${BASE_REF}" > "${LOOP_DIR}/review.diff" || true +git status --porcelain=v1 > "${LOOP_DIR}/review.status" || true +git ls-files --others --exclude-standard > "${LOOP_DIR}/review.untracked" || true # For large repos, keep untracked diffs bounded. -MAX_FILES=${AGENTCORE_REVIEW_UNTRACKED_MAX_FILES:-200} -MAX_FILE_BYTES=${AGENTCORE_REVIEW_UNTRACKED_MAX_FILE_BYTES:-200000} -MAX_TOTAL_BYTES=${AGENTCORE_REVIEW_UNTRACKED_MAX_TOTAL_BYTES:-2000000} +MAX_FILES=${ROBOPPI_REVIEW_UNTRACKED_MAX_FILES:-${AGENTCORE_REVIEW_UNTRACKED_MAX_FILES:-200}} +MAX_FILE_BYTES=${ROBOPPI_REVIEW_UNTRACKED_MAX_FILE_BYTES:-${AGENTCORE_REVIEW_UNTRACKED_MAX_FILE_BYTES:-200000}} +MAX_TOTAL_BYTES=${ROBOPPI_REVIEW_UNTRACKED_MAX_TOTAL_BYTES:-${AGENTCORE_REVIEW_UNTRACKED_MAX_TOTAL_BYTES:-2000000}} -out=.agentcore-loop/review.untracked.diff +out="${LOOP_DIR}/review.untracked.diff" : > "${out}" count=0 @@ -69,4 +79,4 @@ while IFS= read -r f; do # git diff exits 1 when differences exist; ignore that. git diff --no-color --no-index -- /dev/null "${f}" >> "${out}" || true printf '\n' >> "${out}" -done < .agentcore-loop/review.untracked +done < "${LOOP_DIR}/review.untracked" diff --git a/scripts/agent-pr-loop/todo-check.sh b/scripts/agent-pr-loop/todo-check.sh index 6f9a7c1..822496f 100755 --- a/scripts/agent-pr-loop/todo-check.sh +++ b/scripts/agent-pr-loop/todo-check.sh @@ -1,7 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -TODO=.agentcore-loop/todo.md +LOOP_DIR=.roboppi-loop +LEGACY_DIR=.agentcore-loop + +TODO="${LOOP_DIR}/todo.md" +if [ ! -f "${TODO}" ] && [ -f "${LEGACY_DIR}/todo.md" ]; then + TODO="${LEGACY_DIR}/todo.md" +fi test -s "${TODO}" || { echo "CHECK: missing todo"; exit 1; } COUNT=$(grep -c '^- \[ \] ' "${TODO}" || true) diff --git a/scripts/agent-pr-loop/verdict-check.sh b/scripts/agent-pr-loop/verdict-check.sh index 6870e4a..98f7838 100755 --- a/scripts/agent-pr-loop/verdict-check.sh +++ b/scripts/agent-pr-loop/verdict-check.sh @@ -1,7 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -V=$(cat .agentcore-loop/review.verdict 2>/dev/null | tr -d '\r\n\t ') +LOOP_DIR=.roboppi-loop +LEGACY_DIR=.agentcore-loop + +VERDICT_FILE="${LOOP_DIR}/review.verdict" +if [ ! -f "${VERDICT_FILE}" ] && [ -f "${LEGACY_DIR}/review.verdict" ]; then + VERDICT_FILE="${LEGACY_DIR}/review.verdict" +fi + +V=$(cat "${VERDICT_FILE}" 2>/dev/null | tr -d '\r\n\t ') # Preferred: structured JSON verdict file. if [[ "${V}" == *'"decision":"complete"'* ]]; then diff --git a/src/cli.ts b/src/cli.ts index 3d275b1..1ee6ec0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,15 +2,19 @@ /** * AgentCore CLI — execution control runtime for AI agents. * - * Modes: - * agentcore [options] IPC server (stdin/stdout JSON Lines) - * agentcore run --worker ... One-shot task execution + * Modes: + * roboppi [options] Core IPC server (stdin/stdout JSON Lines) + * roboppi run --worker ... One-shot worker task execution + * roboppi workflow ... Workflow runner + * roboppi daemon ... Daemon mode + * + * Compatibility alias: + * agentcore (same CLI) */ -import { JsonLinesTransport } from "./ipc/json-lines-transport.js"; -import { IpcProtocol } from "./ipc/protocol.js"; -import { AgentCore } from "./core/agentcore.js"; +import { applyEnvPrefixAliases } from "./core/env-aliases.js"; import type { AgentCoreConfig } from "./core/agentcore.js"; import type { LogLevel } from "./core/observability.js"; +import { startCoreRuntime } from "./core/core-runtime.js"; import { ExecutionBudget } from "./core/execution-budget.js"; import { BackpressureController } from "./core/backpressure.js"; import { CircuitBreakerRegistry } from "./core/circuit-breaker.js"; @@ -19,12 +23,13 @@ import { ProcessManager } from "./worker/process-manager.js"; import { OpenCodeAdapter } from "./worker/adapters/opencode-adapter.js"; import { ClaudeCodeAdapter } from "./worker/adapters/claude-code-adapter.js"; import { CodexCliAdapter } from "./worker/adapters/codex-cli-adapter.js"; -import { CustomShellAdapter } from "./worker/adapters/custom-shell-adapter.js"; import type { WorkerAdapter } from "./worker/worker-adapter.js"; import { generateId } from "./types/common.js"; import { WorkerKind, WorkerCapability, OutputMode, WorkerStatus, JobType, PriorityClass } from "./types/index.js"; import type { WorkerTask, Job } from "./types/index.js"; +applyEnvPrefixAliases(); + // ── Argument parsing ────────────────────────────────────────────── interface SharedOptions { @@ -54,11 +59,14 @@ interface RunOptions { type CliMode = { mode: "server"; opts: SharedOptions } | { mode: "run"; opts: SharedOptions; run: RunOptions }; const HELP_TEXT = ` -agentcore — AI agent execution control runtime +roboppi — execution-control runtime for agentic workers USAGE - agentcore [options] Start IPC server mode - agentcore run [options] Run a one-shot worker task + roboppi [options] Start Core IPC server mode + roboppi run [options] Run a one-shot worker task + roboppi workflow [options] Run a workflow YAML + roboppi daemon [options] Run daemon mode (resident workflows) + roboppi agent [options] Alias for 'roboppi run' IPC SERVER MODE Reads JSON Lines from stdin, writes responses to stdout. Logs go to stderr. @@ -91,18 +99,24 @@ SHARED OPTIONS EXAMPLES # IPC server mode - echo '{"type":"submit_job",...}' | agentcore + echo '{"type":"submit_job",...}' | roboppi # One-shot: have OpenCode create files - agentcore run --worker opencode --workspace /tmp/demo "Create hello.ts" + roboppi run --worker opencode --workspace /tmp/demo "Create hello.ts" # One-shot: have Claude Code fix tests - agentcore run --worker claude-code --workspace ./my-project \\ + roboppi run --worker claude-code --workspace ./my-project \\ --capabilities EDIT,RUN_TESTS "Fix the failing tests" # One-shot with budget options - agentcore run --worker opencode --workspace /tmp/demo \\ + roboppi run --worker opencode --workspace /tmp/demo \\ --timeout 60000 --concurrency 5 "Write a README" + + # Run a workflow + roboppi workflow examples/hello-world.yaml --verbose + + # Run daemon mode + roboppi daemon examples/daemon/simple-cron.yaml --verbose `.trim(); const VERSION = "0.1.0"; @@ -190,9 +204,9 @@ function parseCliArgs(argv: string[]): CliMode { shared.logLevel = level as LogLevel; continue; } - if (arg.startsWith("-")) die(`unknown option "${arg}"\nRun 'agentcore run --help' for usage.`); - positional.push(arg); - } + if (arg.startsWith("-")) die(`unknown option "${arg}"\nRun 'roboppi run --help' for usage.`); + positional.push(arg); + } run.instructions = positional.join(" "); return { mode: "run", opts: shared, run }; @@ -232,7 +246,7 @@ function parseCliArgs(argv: string[]): CliMode { break; } default: - die(`unknown option "${arg}"\nRun 'agentcore --help' for usage.`); + die(`unknown option "${arg}"\nRun 'roboppi --help' for usage.`); } } return { mode: "server", opts: shared }; @@ -290,9 +304,9 @@ async function executeRun(opts: SharedOptions, run: RunOptions): Promise { const logger = createLogger("run"); // Validate required args - if (!run.worker) die("--worker is required.\nUsage: agentcore run --worker --workspace "); - if (!run.workspace) die("--workspace is required.\nUsage: agentcore run --worker --workspace "); - if (!run.instructions) die("instructions are required.\nUsage: agentcore run --worker --workspace \"your instructions\""); + if (!run.worker) die("--worker is required.\nUsage: roboppi run --worker --workspace "); + if (!run.workspace) die("--workspace is required.\nUsage: roboppi run --worker --workspace "); + if (!run.instructions) die("instructions are required.\nUsage: roboppi run --worker --workspace \"your instructions\""); const workerKind = VALID_WORKERS[run.worker]; if (!workerKind) die(`unknown worker "${run.worker}". Must be one of: ${Object.keys(VALID_WORKERS).join(", ")}`); @@ -435,84 +449,54 @@ async function executeRun(opts: SharedOptions, run: RunOptions): Promise { // ── Server mode ─────────────────────────────────────────────────── function startServer(opts: SharedOptions): void { - const logger = createLogger("cli"); const config = buildConfig(opts); - - // Mark this process as the Core runtime for logging/diagnostics. - if (!process.env.AGENTCORE_COMPONENT) process.env.AGENTCORE_COMPONENT = "core"; - - const stdin = Bun.stdin.stream() as ReadableStream; - const stdout = new WritableStream({ - write(chunk) { process.stdout.write(chunk); }, + startCoreRuntime({ + config, + loggerComponent: "cli", }); +} - const transport = new JsonLinesTransport(stdin, stdout); - const protocol = new IpcProtocol(transport); - const core = new AgentCore(protocol, config); - - // Register built-in worker adapters so WORKER_TASK delegation works. - { - const pm = new ProcessManager(); - core.getWorkerGateway().registerAdapter(WorkerKind.OPENCODE, new OpenCodeAdapter(pm)); - core.getWorkerGateway().registerAdapter(WorkerKind.CLAUDE_CODE, new ClaudeCodeAdapter({}, pm)); - core.getWorkerGateway().registerAdapter(WorkerKind.CODEX_CLI, new CodexCliAdapter(pm)); - core.getWorkerGateway().registerAdapter(WorkerKind.CUSTOM, new CustomShellAdapter(pm)); - } +// ── Main ────────────────────────────────────────────────────────── - let shuttingDown = false; +async function main(): Promise { + const argv = process.argv.slice(2); - async function shutdown(reason: string): Promise { - if (shuttingDown) return; - shuttingDown = true; - logger.info(`Shutting down: ${reason}`); - try { - await core.shutdown(); - } catch (err) { - logger.error("Error during shutdown", err); - } - process.exit(0); + if (argv[0] === "workflow") { + const { runWorkflowCli } = await import("./workflow/run.js"); + await runWorkflowCli(argv.slice(1)); + return; } - process.on("SIGTERM", () => shutdown("SIGTERM")); - process.on("SIGINT", () => shutdown("SIGINT")); - - process.on("uncaughtException", (err) => { - logger.error("Uncaught exception", { name: err.name, message: err.message, stack: err.stack }); - shutdown("uncaughtException").catch(() => process.exit(1)); - }); - - process.on("unhandledRejection", (reason) => { - logger.error("Unhandled rejection", reason); - shutdown("unhandledRejection").catch(() => process.exit(1)); - }); - - logger.info("AgentCore starting", { - config: { - concurrency: config.budget?.maxConcurrency ?? 10, - rps: config.budget?.maxRps ?? 50, - logLevel: config.logLevel ?? "info", - }, - }); - core.start(); - logger.info("AgentCore started, awaiting IPC messages on stdin"); -} + if (argv[0] === "daemon") { + const { runDaemonCli } = await import("./daemon/cli.js"); + await runDaemonCli(argv.slice(1)); + return; + } -// ── Main ────────────────────────────────────────────────────────── + // Alias: `roboppi agent ...` == `roboppi run ...` + const parsed = argv[0] === "agent" + ? parseCliArgs(["run", ...argv.slice(1)]) + : parseCliArgs(argv); -const parsed = parseCliArgs(process.argv.slice(2)); + if (parsed.opts.help) { + console.log(HELP_TEXT); + process.exit(0); + } -if (parsed.opts.help) { - console.log(HELP_TEXT); - process.exit(0); -} + if (parsed.opts.version) { + console.log(`roboppi ${VERSION}`); + process.exit(0); + } -if (parsed.opts.version) { - console.log(`agentcore ${VERSION}`); - process.exit(0); + if (parsed.mode === "run") { + await executeRun(parsed.opts, parsed.run); + } else { + startServer(parsed.opts); + } } -if (parsed.mode === "run") { - executeRun(parsed.opts, parsed.run); -} else { - startServer(parsed.opts); -} +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error: ${msg}\n`); + process.exit(1); +}); diff --git a/src/core/agentcore.ts b/src/core/agentcore.ts index c49df3e..610d206 100644 --- a/src/core/agentcore.ts +++ b/src/core/agentcore.ts @@ -5,7 +5,7 @@ import type { EscalationEvent, UUID, } from "../types/index.js"; -import { JobType, ErrorClass, PermitRejectionReason, now } from "../types/index.js"; +import { JobType, ErrorClass, PermitRejectionReason, now, isWorkerTaskJobPayload } from "../types/index.js"; import { WorkerStatus } from "../types/index.js"; import type { IpcProtocol } from "../ipc/protocol.js"; import { CancellationManager } from "./cancellation.js"; @@ -252,27 +252,31 @@ export class AgentCore { permit: PermitHandle, logger: ReturnType, ): void { - const payload = job.payload as { - workerTaskId: string; - workerKind: string; - workspaceRef: string; - instructions: string; - model?: string; - capabilities: string[]; - outputMode: string; - budget: { deadlineAt: number; maxSteps?: number; maxCommandTimeMs?: number }; - env?: Record; - }; + if (!isWorkerTaskJobPayload(job.payload)) { + logger.error("Invalid WORKER_TASK payload", { jobId: job.jobId }); + this.permitGate.completePermit(permit.permitId); + this.cancellation.removeController(permit.permitId); + this.activePermitsByJob.delete(job.jobId); + this.jobs.delete(job.jobId); + this.protocol + .sendJobCompleted(job.jobId, "failed", undefined, ErrorClass.NON_RETRYABLE) + .catch((err: unknown) => { + logger.error("Failed to send job_completed for invalid payload", { error: err }); + }); + return; + } + + const payload = job.payload; const workerTask = { - workerTaskId: payload.workerTaskId ?? job.jobId, + workerTaskId: payload.workerTaskId, workerKind: payload.workerKind, workspaceRef: payload.workspaceRef, instructions: payload.instructions, ...(payload.model ? { model: payload.model } : {}), - capabilities: payload.capabilities ?? [], - outputMode: payload.outputMode ?? "BATCH", - budget: payload.budget ?? { deadlineAt: permit.deadlineAt }, + capabilities: payload.capabilities, + outputMode: payload.outputMode, + budget: payload.budget, ...(payload.env ? { env: payload.env } : {}), abortSignal: permit.abortController.signal, }; diff --git a/src/core/core-runtime.ts b/src/core/core-runtime.ts new file mode 100644 index 0000000..e1f2dfb --- /dev/null +++ b/src/core/core-runtime.ts @@ -0,0 +1,217 @@ +import { JsonLinesTransport } from "../ipc/json-lines-transport.js"; +import { IpcProtocol } from "../ipc/protocol.js"; +import { AgentCore, type AgentCoreConfig } from "./agentcore.js"; +import { applyEnvPrefixAliases } from "./env-aliases.js"; +import { Logger } from "./observability.js"; + +import { ProcessManager } from "../worker/process-manager.js"; +import { OpenCodeAdapter } from "../worker/adapters/opencode-adapter.js"; +import { ClaudeCodeAdapter } from "../worker/adapters/claude-code-adapter.js"; +import { CodexCliAdapter } from "../worker/adapters/codex-cli-adapter.js"; +import { CustomShellAdapter } from "../worker/adapters/custom-shell-adapter.js"; +import { WorkerKind } from "../types/index.js"; + +import { createConnection, type Socket } from "node:net"; +import { Readable, Writable } from "node:stream"; + +export interface CoreRuntimeOptions { + config?: AgentCoreConfig; + loggerComponent?: string; +} + +function isNonInteractive(): boolean { + // Treat either stream being a TTY as interactive. + return !(process.stdout.isTTY || process.stderr.isTTY); +} + +function parseEnvBool(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const v = value.trim().toLowerCase(); + if (v === "1" || v === "true" || v === "yes" || v === "on") return true; + if (v === "0" || v === "false" || v === "no" || v === "off") return false; + return undefined; +} + +function parseDurationMs(input: string): number { + const trimmed = input.trim(); + if (trimmed === "") throw new Error(`Invalid duration string: "${input}"`); + + const tokenRe = /(\d+)(ms|s|m|h)/g; + let totalMs = 0; + let matchedLen = 0; + for (const match of trimmed.matchAll(tokenRe)) { + const value = parseInt(match[1]!, 10); + const unit = match[2]!; + matchedLen += match[0]!.length; + switch (unit) { + case "h": + totalMs += value * 3600_000; + break; + case "m": + totalMs += value * 60_000; + break; + case "s": + totalMs += value * 1000; + break; + case "ms": + totalMs += value; + break; + } + } + if (matchedLen !== trimmed.length || totalMs <= 0) { + throw new Error(`Invalid duration string: "${input}"`); + } + return totalMs; +} + +export function startCoreRuntime(options: CoreRuntimeOptions = {}): void { + const config = options.config; + const logger = new Logger(options.loggerComponent ?? "main", config?.logLevel ?? "info"); + + // Mark this process as the Core runtime for logging/diagnostics. + applyEnvPrefixAliases(); + if (!process.env.ROBOPPI_COMPONENT) process.env.ROBOPPI_COMPONENT = "core"; + if (!process.env.AGENTCORE_COMPONENT) process.env.AGENTCORE_COMPONENT = "core"; + applyEnvPrefixAliases(); + + // IPC transport: JSONL over either stdio or a supervised IPC socket. + const ipcSocketPath = (process.env.AGENTCORE_IPC_SOCKET_PATH ?? process.env.ROBOPPI_IPC_SOCKET_PATH)?.trim(); + const ipcSocketHost = (process.env.AGENTCORE_IPC_SOCKET_HOST ?? process.env.ROBOPPI_IPC_SOCKET_HOST)?.trim(); + const ipcSocketPortRaw = (process.env.AGENTCORE_IPC_SOCKET_PORT ?? process.env.ROBOPPI_IPC_SOCKET_PORT)?.trim(); + + let ipcSocket: Socket | null = null; + let ipcSocketKind: "unix" | "tcp" | null = null; + let resolvedIpcHost: string | undefined; + let resolvedIpcPort: number | undefined; + + // Prefer TCP host/port when provided (useful for environments where Unix sockets are blocked). + if (ipcSocketPortRaw) { + const port = Number.parseInt(ipcSocketPortRaw, 10); + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid AGENTCORE_IPC_SOCKET_PORT: "${ipcSocketPortRaw}"`); + } + const host = ipcSocketHost && ipcSocketHost !== "" ? ipcSocketHost : "127.0.0.1"; + ipcSocket = createConnection({ host, port }); + ipcSocketKind = "tcp"; + resolvedIpcHost = host; + resolvedIpcPort = port; + } else if (ipcSocketPath) { + ipcSocket = createConnection(ipcSocketPath); + ipcSocketKind = "unix"; + } + + if (ipcSocket) { + try { + ipcSocket.setNoDelay(true); + } catch { + // ignore + } + + // Prevent unhandled socket errors from crashing the Core process. + ipcSocket.on("error", (err) => { + logger.error("IPC socket error", { + name: err.name, + message: err.message, + }); + }); + } + + const stdin = ipcSocket + ? (Readable.toWeb(ipcSocket) as unknown as ReadableStream) + : (() => { + // Prefer Bun's native stdin stream, but fall back to Node's process.stdin stream + // when Bun stdin stream plumbing differs in some environments. + const bunStdin = Bun.stdin as { stream?: () => ReadableStream } | undefined; + if (bunStdin?.stream) { + return bunStdin.stream() as ReadableStream; + } + + return Readable.toWeb(process.stdin) as unknown as ReadableStream; + })(); + + const stdout = ipcSocket + ? (Writable.toWeb(ipcSocket) as unknown as WritableStream) + : (Writable.toWeb(process.stdout) as unknown as WritableStream); + + const transport = new JsonLinesTransport(stdin, stdout); + const protocol = new IpcProtocol(transport); + const core = new AgentCore(protocol, config); + + // Keepalive output for environments that kill silent subprocesses. + // Enabled by default when non-interactive; can be overridden via env. + const keepaliveEnabled = parseEnvBool(process.env.AGENTCORE_KEEPALIVE) ?? isNonInteractive(); + const keepaliveIntervalMs = (() => { + const raw = process.env.AGENTCORE_KEEPALIVE_INTERVAL ?? "10s"; + try { + return parseDurationMs(raw); + } catch { + return 10_000; + } + })(); + const keepaliveStartedAt = Date.now(); + let keepaliveTimer: ReturnType | null = null; + if (keepaliveEnabled) { + keepaliveTimer = setInterval(() => { + const activeWorkers = core.getWorkerGateway().getActiveWorkerCount(); + if (activeWorkers <= 0) return; + const elapsedS = Math.floor((Date.now() - keepaliveStartedAt) / 1000); + logger.info("Keepalive", { activeWorkers, elapsedS }); + }, keepaliveIntervalMs); + } + + // Register built-in worker adapters so WORKER_TASK delegation works. + { + const pm = new ProcessManager(); + core.getWorkerGateway().registerAdapter(WorkerKind.OPENCODE, new OpenCodeAdapter(pm)); + core.getWorkerGateway().registerAdapter(WorkerKind.CLAUDE_CODE, new ClaudeCodeAdapter({}, pm)); + core.getWorkerGateway().registerAdapter(WorkerKind.CODEX_CLI, new CodexCliAdapter(pm)); + core.getWorkerGateway().registerAdapter(WorkerKind.CUSTOM, new CustomShellAdapter(pm)); + } + + let shuttingDown = false; + + async function shutdown(reason: string): Promise { + if (shuttingDown) return; + shuttingDown = true; + logger.info(`Shutting down: ${reason}`); + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + } + try { + await core.shutdown(); + } catch (err) { + logger.error("Error during shutdown", err); + } + process.exit(0); + } + + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + + process.on("uncaughtException", (err) => { + logger.error("Uncaught exception", { name: err.name, message: err.message, stack: err.stack }); + shutdown("uncaughtException").catch(() => process.exit(1)); + }); + + process.on("unhandledRejection", (reason) => { + logger.error("Unhandled rejection", reason); + shutdown("unhandledRejection").catch(() => process.exit(1)); + }); + + logger.info("AgentCore starting", { + config: { + concurrency: config?.budget?.maxConcurrency ?? 10, + rps: config?.budget?.maxRps ?? 50, + logLevel: config?.logLevel ?? "info", + }, + }); + + core.start(); + + logger.info("AgentCore started, awaiting IPC messages", { + transport: ipcSocket ? (ipcSocketKind === "tcp" ? "tcp" : "socket") : "stdio", + socketPath: ipcSocketKind === "unix" ? (ipcSocketPath || undefined) : undefined, + socketHost: ipcSocketKind === "tcp" ? resolvedIpcHost : undefined, + socketPort: ipcSocketKind === "tcp" ? resolvedIpcPort : undefined, + }); +} diff --git a/src/core/env-aliases.ts b/src/core/env-aliases.ts new file mode 100644 index 0000000..c096041 --- /dev/null +++ b/src/core/env-aliases.ts @@ -0,0 +1,31 @@ +/** + * Environment variable compatibility layer. + * + * Roboppi is the product name, but this repo historically used the `AGENTCORE_` + * prefix for many environment variables. + * + * To keep backward compatibility while moving docs/examples to `ROBOPPI_`, we + * mirror values across prefixes (without overriding explicitly-set values). + */ + +export function applyEnvPrefixAliases(): void { + const env = process.env as Record; + + for (const [key, value] of Object.entries(env)) { + if (value === undefined) continue; + + if (key.startsWith("ROBOPPI_")) { + const suffix = key.slice("ROBOPPI_".length); + const legacy = `AGENTCORE_${suffix}`; + if (env[legacy] === undefined) env[legacy] = value; + continue; + } + + if (key.startsWith("AGENTCORE_")) { + const suffix = key.slice("AGENTCORE_".length); + const modern = `ROBOPPI_${suffix}`; + if (env[modern] === undefined) env[modern] = value; + continue; + } + } +} diff --git a/src/daemon/cli.ts b/src/daemon/cli.ts index 2b82039..01b6929 100644 --- a/src/daemon/cli.ts +++ b/src/daemon/cli.ts @@ -3,66 +3,118 @@ * Daemon CLI * * Usage: - * bun run src/daemon/cli.ts [--workspace ] [--verbose] [--supervised] + * roboppi daemon [--workspace ] [--verbose] [--direct] + * (dev) bun run src/daemon/cli.ts [--workspace ] [--verbose] [--direct] */ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { parseDaemonConfig } from "./parser.js"; import { Daemon } from "./daemon.js"; +import { applyEnvPrefixAliases } from "../core/env-aliases.js"; -// -- Argument parsing -- - -const args = process.argv.slice(2); -let yamlPath = ""; -let verbose = false; -let workspaceOverride = ""; -let supervised = false; - -for (let i = 0; i < args.length; i++) { - const arg = args[i]!; - const next = (): string => { - i++; - const val = args[i]; - if (val === undefined) { - console.error(`Error: ${arg} requires a value`); - process.exit(1); - } - return val; - }; +function isNonInteractive(): boolean { + // Treat either stream being a TTY as interactive. + return !(process.stdout.isTTY || process.stderr.isTTY); +} + +function isRunningUnderBun(): boolean { + const base = path.basename(process.execPath).toLowerCase(); + return base === "bun" || base === "bun.exe"; +} + +function resolveCoreEntryPointForSupervised(coreEntryPointOverride: string | undefined): string { + const fromCli = coreEntryPointOverride?.trim(); + if (fromCli) return fromCli; + + const fromEnv = (process.env.ROBOPPI_CORE_ENTRYPOINT ?? process.env.AGENTCORE_CORE_ENTRYPOINT)?.trim(); + if (fromEnv) return fromEnv; + + // Compiled binary: spawn this executable as the Core process. + if (!isRunningUnderBun()) return process.execPath; + + return path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "index.ts", + ); +} - if (arg === "--verbose" || arg === "-v") { - verbose = true; - } else if (arg === "--workspace" || arg === "-w") { - workspaceOverride = next(); - } else if (arg === "--supervised") { - supervised = true; - } else if (arg === "--help" || arg === "-h") { - console.log(`Usage: bun run src/daemon/cli.ts [options] +export async function runDaemonCli(argv: string[]): Promise { + applyEnvPrefixAliases(); + + const args = argv; + let yamlPath = ""; + let verbose = false; + let workspaceOverride = ""; + let supervised = true; + let coreEntryPointOverride: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + const next = (): string => { + i++; + const val = args[i]; + if (val === undefined) { + console.error(`Error: ${arg} requires a value`); + process.exit(1); + } + return val; + }; + + if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if (arg === "--workspace" || arg === "-w") { + workspaceOverride = next(); + } else if (arg === "--supervised") { + supervised = true; + } else if (arg === "--direct" || arg === "--no-supervised") { + supervised = false; + } else if (arg === "--core" || arg === "--core-entrypoint") { + coreEntryPointOverride = next(); + } else if (arg === "--help" || arg === "-h") { + console.log(`Usage: + roboppi daemon [options] + (dev) bun run src/daemon/cli.ts [options] Options: --workspace, -w Override workspace directory --verbose, -v Enable verbose logging - --supervised Run workflows via Core IPC (Supervisor -> Core -> Worker) + --supervised Supervised mode (default): run workflows via Core IPC (Supervisor -> Core -> Worker) + --direct Direct mode: spawn worker CLIs directly (no Core IPC) + --no-supervised Alias for --direct + --core Core entrypoint for supervised mode (default: auto) --help, -h Show help`); - process.exit(0); - } else if (!arg.startsWith("-")) { - yamlPath = arg; + process.exit(0); + } else if (!arg.startsWith("-")) { + yamlPath = arg; + } } -} -if (verbose) { - process.env.AGENTCORE_VERBOSE = "1"; -} + if (verbose) { + process.env.ROBOPPI_VERBOSE = "1"; + process.env.AGENTCORE_VERBOSE = "1"; + } -if (!yamlPath) { - console.error("Error: daemon YAML path is required"); - console.error("Usage: bun run src/daemon/cli.ts "); - process.exit(1); -} + // Default supervised IPC transport. + // Mirror workflow runner behavior: prefer socket transport in non-interactive mode. + // (Override via ROBOPPI_SUPERVISED_IPC_TRANSPORT or AGENTCORE_SUPERVISED_IPC_TRANSPORT.) + if ( + supervised && + process.env.ROBOPPI_SUPERVISED_IPC_TRANSPORT === undefined && + process.env.AGENTCORE_SUPERVISED_IPC_TRANSPORT === undefined + ) { + const val = isNonInteractive() ? "socket" : "stdio"; + process.env.ROBOPPI_SUPERVISED_IPC_TRANSPORT = val; + process.env.AGENTCORE_SUPERVISED_IPC_TRANSPORT = val; + } -// -- Main -- + if (!yamlPath) { + console.error("Error: daemon YAML path is required"); + console.error("Usage: roboppi daemon [options]"); + process.exit(1); + } -async function main(): Promise { const resolvedPath = path.resolve(yamlPath); const yamlContent = await readFile(resolvedPath, "utf-8"); const parsed = parseDaemonConfig(yamlContent); @@ -81,7 +133,13 @@ async function main(): Promise { console.log(`Triggers: ${triggerCount}`); console.log(""); - const daemon = new Daemon(config, undefined, { supervised }); + const daemon = new Daemon( + config, + undefined, + supervised + ? { supervised, coreEntryPoint: resolveCoreEntryPointForSupervised(coreEntryPointOverride) } + : { supervised }, + ); await daemon.start(); } @@ -99,6 +157,7 @@ function expandEnvInConfig(config: ReturnType): Return const expanded = { ...config, workspace: expandEnvString(config.workspace, "workspace"), + agents_file: config.agents_file ? expandEnvString(config.agents_file, "agents_file") : undefined, state_dir: config.state_dir ? expandEnvString(config.state_dir, "state_dir") : undefined, log_dir: config.log_dir ? expandEnvString(config.log_dir, "log_dir") : undefined, triggers: Object.fromEntries( @@ -122,7 +181,9 @@ function applyWorkspaceOverride(config: ReturnType, wo }; } -main().catch((err) => { - console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); -}); +if (import.meta.main) { + runDaemonCli(process.argv.slice(2)).catch((err: unknown) => { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + }); +} diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index 6a3b6a0..f7358a9 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -17,12 +17,17 @@ import { CommandSource } from "./events/command-source.js"; import { mergeEventSources } from "./events/event-source.js"; import type { EventSource } from "./events/event-source.js"; import { parseWorkflow } from "../workflow/parser.js"; +import { parseAgentCatalog, type AgentCatalog } from "../workflow/agent-catalog.js"; import type { StepRunner } from "../workflow/executor.js"; import { WorkflowExecutor } from "../workflow/executor.js"; import { MultiWorkerStepRunner } from "../workflow/multi-worker-step-runner.js"; import { CoreIpcStepRunner } from "../workflow/core-ipc-step-runner.js"; import { ContextManager } from "../workflow/context-manager.js"; import { Logger } from "../core/observability.js"; +import { + resolveBranchRuntimeContext, + type BranchRuntimeContext, +} from "../workflow/branch-context.js"; export interface DaemonOptions { supervised?: boolean; @@ -30,6 +35,14 @@ export interface DaemonOptions { coreEntryPoint?: string; } +function splitPathList(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(":") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + interface QueuedItem { triggerId: string; trigger: TriggerDef; @@ -407,8 +420,8 @@ export class Daemon { } // 1b. Context injection — build env to pass to executor (not process.env) - const workflowEnv: Record | undefined = - trigger.context?.env ? { ...trigger.context.env } : undefined; + const workflowEnv: Record = + trigger.context?.env ? { ...trigger.context.env } : {}; const daemonContextDir = path.join(workspaceDir, ".daemon-context"); await mkdir(daemonContextDir, { recursive: true }); @@ -431,7 +444,74 @@ export class Daemon { // 2. Read and parse the workflow YAML const workflowPath = path.resolve(workspaceDir, trigger.workflow); const yamlContent = await readFile(workflowPath, "utf-8"); - const definition = parseWorkflow(yamlContent); + const agentCatalog = await this.loadAgentCatalogForWorkflow(workflowPath, workflowEnv); + const definition = parseWorkflow(yamlContent, { agents: agentCatalog }); + + const branchContext = await resolveBranchRuntimeContext({ + workspaceDir, + envBaseBranch: workflowEnv.BASE_BRANCH ?? process.env.BASE_BRANCH, + envProtectedBranches: + workflowEnv.ROBOPPI_PROTECTED_BRANCHES ?? + workflowEnv.AGENTCORE_PROTECTED_BRANCHES ?? + process.env.ROBOPPI_PROTECTED_BRANCHES ?? + process.env.AGENTCORE_PROTECTED_BRANCHES, + envAllowProtectedBranch: + workflowEnv.ROBOPPI_ALLOW_PROTECTED_BRANCH ?? + workflowEnv.AGENTCORE_ALLOW_PROTECTED_BRANCH ?? + process.env.ROBOPPI_ALLOW_PROTECTED_BRANCH ?? + process.env.AGENTCORE_ALLOW_PROTECTED_BRANCH, + createBranch: definition.create_branch ?? false, + expectedWorkBranch: definition.expected_work_branch, + branchTransitionStep: definition.branch_transition_step, + stepIds: Object.keys(definition.steps), + }); + this.logBranchContext(safeTriggerId, branchContext); + + workflowEnv.AGENTCORE_CREATE_BRANCH = branchContext.createBranch ? "1" : "0"; + workflowEnv.ROBOPPI_CREATE_BRANCH = workflowEnv.AGENTCORE_CREATE_BRANCH; + workflowEnv.AGENTCORE_PROTECTED_BRANCHES = branchContext.protectedBranches.join(","); + workflowEnv.ROBOPPI_PROTECTED_BRANCHES = workflowEnv.AGENTCORE_PROTECTED_BRANCHES; + workflowEnv.AGENTCORE_ALLOW_PROTECTED_BRANCH = branchContext.allowProtectedBranch + ? "1" + : "0"; + workflowEnv.ROBOPPI_ALLOW_PROTECTED_BRANCH = workflowEnv.AGENTCORE_ALLOW_PROTECTED_BRANCH; + if (branchContext.effectiveBaseBranch) { + workflowEnv.BASE_BRANCH = branchContext.effectiveBaseBranch; + workflowEnv.AGENTCORE_EFFECTIVE_BASE_BRANCH = branchContext.effectiveBaseBranch; + workflowEnv.ROBOPPI_EFFECTIVE_BASE_BRANCH = branchContext.effectiveBaseBranch; + } + if (branchContext.effectiveBaseBranchSource) { + workflowEnv.AGENTCORE_EFFECTIVE_BASE_BRANCH_SOURCE = + branchContext.effectiveBaseBranchSource; + workflowEnv.ROBOPPI_EFFECTIVE_BASE_BRANCH_SOURCE = + branchContext.effectiveBaseBranchSource; + } + if (branchContext.effectiveBaseSha) { + workflowEnv.AGENTCORE_EFFECTIVE_BASE_SHA = branchContext.effectiveBaseSha; + workflowEnv.ROBOPPI_EFFECTIVE_BASE_SHA = branchContext.effectiveBaseSha; + } + if (branchContext.startupBranch) { + workflowEnv.AGENTCORE_STARTUP_BRANCH = branchContext.startupBranch; + workflowEnv.ROBOPPI_STARTUP_BRANCH = branchContext.startupBranch; + } + if (branchContext.startupHeadSha) { + workflowEnv.AGENTCORE_STARTUP_HEAD_SHA = branchContext.startupHeadSha; + workflowEnv.ROBOPPI_STARTUP_HEAD_SHA = branchContext.startupHeadSha; + } + if (branchContext.startupToplevel) { + workflowEnv.AGENTCORE_STARTUP_TOPLEVEL = branchContext.startupToplevel; + workflowEnv.ROBOPPI_STARTUP_TOPLEVEL = branchContext.startupToplevel; + } + if (branchContext.expectedWorkBranch) { + workflowEnv.AGENTCORE_EXPECTED_WORK_BRANCH = branchContext.expectedWorkBranch; + workflowEnv.ROBOPPI_EXPECTED_WORK_BRANCH = branchContext.expectedWorkBranch; + } + if (branchContext.expectedCurrentBranch) { + workflowEnv.AGENTCORE_EXPECTED_CURRENT_BRANCH = branchContext.expectedCurrentBranch; + workflowEnv.ROBOPPI_EXPECTED_CURRENT_BRANCH = branchContext.expectedCurrentBranch; + } + const executorEnv = + Object.keys(workflowEnv).length > 0 ? workflowEnv : undefined; // 3. Setup context dir const contextDir = path.join(workspaceDir, "context", safeTriggerId); @@ -444,8 +524,9 @@ export class Daemon { ctx, this.stepRunner, workspaceDir, - workflowEnv, + executorEnv, this.shutdownAbortController.signal, + branchContext, ); const result = await executor.execute(); @@ -480,4 +561,87 @@ export class Daemon { } } } + + private async loadAgentCatalogForWorkflow( + workflowPath: string, + workflowEnv: Record, + ): Promise { + const fromProcessEnv = splitPathList( + process.env.ROBOPPI_AGENTS_FILE ?? process.env.AGENTCORE_AGENTS_FILE, + ); + const fromConfig = this.config.agents_file ? [this.config.agents_file] : []; + const fromWorkflowEnv = splitPathList( + workflowEnv.ROBOPPI_AGENTS_FILE ?? workflowEnv.AGENTCORE_AGENTS_FILE, + ); + + // Precedence: process env (lowest) -> daemon config -> trigger env (highest). + const explicit = [...fromProcessEnv, ...fromConfig, ...fromWorkflowEnv]; + + const resolveFromWorkspace = (p: string): string => + path.isAbsolute(p) ? p : path.resolve(this.workspaceDir, p); + + const candidates: string[] = []; + if (explicit.length > 0) { + candidates.push(...explicit.map(resolveFromWorkspace)); + } else { + const dir = path.dirname(workflowPath); + candidates.push(path.join(dir, "agents.yaml")); + candidates.push(path.join(dir, "agents.yml")); + } + + let catalog: AgentCatalog | undefined; + for (const p of candidates) { + try { + const content = await readFile(p, "utf-8"); + const parsed = parseAgentCatalog(content); + catalog = { ...(catalog ?? {}), ...parsed }; + } catch (err: unknown) { + const code = (err as any)?.code; + + // In implicit mode, missing default files are ignored. + if (explicit.length === 0 && code === "ENOENT") { + continue; + } + + if (code === "ENOENT") { + throw new Error(`Agent catalog not found: ${p}`); + } + throw err; + } + } + + return catalog; + } + + private logBranchContext(triggerId: string, context: BranchRuntimeContext): void { + if (!context.enabled) { + for (const warning of context.warnings) { + if (warning.includes("not a git repository")) { + this.logger.debug("Branch lock disabled", { triggerId, warning }); + } else { + this.logger.warn("Branch lock warning", { triggerId, warning }); + } + } + return; + } + + this.logger.info("Branch lock resolved", { + triggerId, + startup_branch: context.startupBranch, + startup_head_sha: context.startupHeadSha, + startup_toplevel: context.startupToplevel, + effective_base_branch: context.effectiveBaseBranch, + effective_base_branch_source: context.effectiveBaseBranchSource, + effective_base_sha: context.effectiveBaseSha, + create_branch: context.createBranch, + expected_work_branch: context.expectedWorkBranch, + protected_branches: context.protectedBranches, + protected_branches_source: context.protectedBranchesSource, + allow_protected_branch: context.allowProtectedBranch, + }); + + for (const warning of context.warnings) { + this.logger.warn("Branch lock warning", { triggerId, warning }); + } + } } diff --git a/src/daemon/evaluate-gate.ts b/src/daemon/evaluate-gate.ts index 0d72ad4..54dcf68 100644 --- a/src/daemon/evaluate-gate.ts +++ b/src/daemon/evaluate-gate.ts @@ -80,10 +80,13 @@ export class EvaluateGate { } // Pass template variables as environment variables to prevent shell injection. - // The shell command can reference them as $AGENTCORE_EVENT, $AGENTCORE_TRIGGER_ID, etc. + // The shell command can reference them as $ROBOPPI_EVENT/$AGENTCORE_EVENT, + // $ROBOPPI_TRIGGER_ID/$AGENTCORE_TRIGGER_ID, etc. const env: Record = { ...process.env as Record }; for (const [key, value] of Object.entries(vars)) { - env[`AGENTCORE_${key.toUpperCase()}`] = value; + const upper = key.toUpperCase(); + env[`AGENTCORE_${upper}`] = value; + env[`ROBOPPI_${upper}`] = value; } try { @@ -163,7 +166,9 @@ export class EvaluateGate { ): Promise { const env: Record = {}; for (const [key, value] of Object.entries(vars)) { - env[`AGENTCORE_${key.toUpperCase()}`] = value; + const upper = key.toUpperCase(); + env[`AGENTCORE_${upper}`] = value; + env[`ROBOPPI_${upper}`] = value; } const step: StepDefinition = { diff --git a/src/daemon/parser.ts b/src/daemon/parser.ts index 7f073cf..7398f92 100644 --- a/src/daemon/parser.ts +++ b/src/daemon/parser.ts @@ -383,6 +383,10 @@ export function parseDaemonConfig(yamlContent: string): DaemonConfig { assertString(doc["workspace"], "workspace"); + if (doc["agents_file"] !== undefined) { + assertString(doc["agents_file"], "agents_file"); + } + // events assertObject(doc["events"], "events"); const eventsObj = doc["events"] as Record; @@ -415,6 +419,7 @@ export function parseDaemonConfig(yamlContent: string): DaemonConfig { version: "1", description: typeof doc["description"] === "string" ? doc["description"] : undefined, workspace: doc["workspace"] as string, + agents_file: typeof doc["agents_file"] === "string" ? doc["agents_file"] : undefined, log_dir: typeof doc["log_dir"] === "string" ? doc["log_dir"] : undefined, max_concurrent_workflows: typeof doc["max_concurrent_workflows"] === "number" diff --git a/src/daemon/result-analyzer.ts b/src/daemon/result-analyzer.ts index 720431f..3589ef0 100644 --- a/src/daemon/result-analyzer.ts +++ b/src/daemon/result-analyzer.ts @@ -55,7 +55,9 @@ export class ResultAnalyzer { // Pass template variables as environment variables to prevent shell injection. const varsEnv: Record = {}; for (const [key, value] of Object.entries(vars)) { - varsEnv[`AGENTCORE_${key.toUpperCase()}`] = value; + const upper = key.toUpperCase(); + varsEnv[`AGENTCORE_${upper}`] = value; + varsEnv[`ROBOPPI_${upper}`] = value; } const env: Record = { ...process.env as Record, ...varsEnv }; diff --git a/src/daemon/types.ts b/src/daemon/types.ts index a614bcd..5955cc0 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -10,6 +10,8 @@ export interface DaemonConfig { version: "1"; description?: string; workspace: string; + /** Optional agent catalog YAML used to resolve workflow step.agent references. */ + agents_file?: string; log_dir?: string; max_concurrent_workflows?: number; state_dir?: string; diff --git a/src/index.ts b/src/index.ts index bd16dd1..cf74dd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,210 +1,3 @@ -import { JsonLinesTransport } from "./ipc/json-lines-transport.js"; -import { IpcProtocol } from "./ipc/protocol.js"; -import { AgentCore } from "./core/agentcore.js"; -import { ProcessManager } from "./worker/process-manager.js"; -import { OpenCodeAdapter } from "./worker/adapters/opencode-adapter.js"; -import { ClaudeCodeAdapter } from "./worker/adapters/claude-code-adapter.js"; -import { CodexCliAdapter } from "./worker/adapters/codex-cli-adapter.js"; -import { CustomShellAdapter } from "./worker/adapters/custom-shell-adapter.js"; -import { WorkerKind } from "./types/index.js"; -import { createConnection, type Socket } from "node:net"; -import { Readable, Writable } from "node:stream"; +import { startCoreRuntime } from "./core/core-runtime.js"; -function isNonInteractive(): boolean { - // Treat either stream being a TTY as interactive. - return !(process.stdout.isTTY || process.stderr.isTTY); -} - -function parseEnvBool(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const v = value.trim().toLowerCase(); - if (v === "1" || v === "true" || v === "yes" || v === "on") return true; - if (v === "0" || v === "false" || v === "no" || v === "off") return false; - return undefined; -} - -function parseDurationMs(input: string): number { - const trimmed = input.trim(); - if (trimmed === "") throw new Error(`Invalid duration string: "${input}"`); - - const tokenRe = /(\d+)(ms|s|m|h)/g; - let totalMs = 0; - let matchedLen = 0; - for (const match of trimmed.matchAll(tokenRe)) { - const value = parseInt(match[1]!, 10); - const unit = match[2]!; - matchedLen += match[0]!.length; - switch (unit) { - case "h": - totalMs += value * 3600_000; - break; - case "m": - totalMs += value * 60_000; - break; - case "s": - totalMs += value * 1000; - break; - case "ms": - totalMs += value; - break; - } - } - if (matchedLen !== trimmed.length || totalMs <= 0) { - throw new Error(`Invalid duration string: "${input}"`); - } - return totalMs; -} - -// Mark this process as the Core runtime for logging/diagnostics. -if (!process.env.AGENTCORE_COMPONENT) process.env.AGENTCORE_COMPONENT = "core"; - -const logger = { - error(msg: string, data?: unknown) { - process.stderr.write( - JSON.stringify({ timestamp: Date.now(), level: "error", component: "main", message: msg, data }) + "\n", - ); - }, - info(msg: string, data?: unknown) { - const entry: Record = { - timestamp: Date.now(), - level: "info", - component: "main", - message: msg, - }; - if (data !== undefined) entry.data = data; - process.stderr.write(JSON.stringify(entry) + "\n"); - }, -}; - -// IPC transport: JSONL over either stdio or a supervised IPC socket. -const ipcSocketPath = process.env.AGENTCORE_IPC_SOCKET_PATH?.trim(); -const ipcSocketHost = process.env.AGENTCORE_IPC_SOCKET_HOST?.trim(); -const ipcSocketPortRaw = process.env.AGENTCORE_IPC_SOCKET_PORT?.trim(); - -let ipcSocket: Socket | null = null; -let ipcSocketKind: "unix" | "tcp" | null = null; -let resolvedIpcHost: string | undefined; -let resolvedIpcPort: number | undefined; - -// Prefer TCP host/port when provided (useful for environments where Unix sockets are blocked). -if (ipcSocketPortRaw) { - const port = Number.parseInt(ipcSocketPortRaw, 10); - if (!Number.isFinite(port) || port <= 0 || port > 65535) { - throw new Error(`Invalid AGENTCORE_IPC_SOCKET_PORT: "${ipcSocketPortRaw}"`); - } - const host = ipcSocketHost && ipcSocketHost !== "" ? ipcSocketHost : "127.0.0.1"; - ipcSocket = createConnection({ host, port }); - ipcSocketKind = "tcp"; - resolvedIpcHost = host; - resolvedIpcPort = port; -} else if (ipcSocketPath) { - ipcSocket = createConnection(ipcSocketPath); - ipcSocketKind = "unix"; -} - -if (ipcSocket) { - try { - ipcSocket.setNoDelay(true); - } catch { - // ignore - } - - // Prevent unhandled socket errors from crashing the Core process. - ipcSocket.on("error", (err) => { - logger.error("IPC socket error", { - name: err.name, - message: err.message, - }); - }); -} - -const stdin = ipcSocket - ? (Readable.toWeb(ipcSocket) as unknown as ReadableStream) - : (() => { - // Prefer Bun's native stdin stream, but fall back to Node's process.stdin stream - // when Bun stdin stream plumbing differs in some environments. - const bunStdin = Bun.stdin as { stream?: () => ReadableStream } | undefined; - if (bunStdin?.stream) { - return bunStdin.stream() as ReadableStream; - } - - return Readable.toWeb(process.stdin) as unknown as ReadableStream; - })(); - -const stdout = ipcSocket - ? (Writable.toWeb(ipcSocket) as unknown as WritableStream) - : (Writable.toWeb(process.stdout) as unknown as WritableStream); - -const transport = new JsonLinesTransport(stdin, stdout); -const protocol = new IpcProtocol(transport); -const core = new AgentCore(protocol); - -// Keepalive output for environments that kill silent subprocesses. -// Enabled by default when non-interactive; can be overridden via env. -const keepaliveEnabled = parseEnvBool(process.env.AGENTCORE_KEEPALIVE) ?? isNonInteractive(); -const keepaliveIntervalMs = (() => { - const raw = process.env.AGENTCORE_KEEPALIVE_INTERVAL ?? "10s"; - try { - return parseDurationMs(raw); - } catch { - return 10_000; - } -})(); -const keepaliveStartedAt = Date.now(); -let keepaliveTimer: ReturnType | null = null; -if (keepaliveEnabled) { - keepaliveTimer = setInterval(() => { - const activeWorkers = core.getWorkerGateway().getActiveWorkerCount(); - if (activeWorkers <= 0) return; - const elapsedS = Math.floor((Date.now() - keepaliveStartedAt) / 1000); - logger.info("Keepalive", { activeWorkers, elapsedS }); - }, keepaliveIntervalMs); -} - -// Register built-in worker adapters so WORKER_TASK delegation works. -{ - const pm = new ProcessManager(); - core.getWorkerGateway().registerAdapter(WorkerKind.OPENCODE, new OpenCodeAdapter(pm)); - core.getWorkerGateway().registerAdapter(WorkerKind.CLAUDE_CODE, new ClaudeCodeAdapter({}, pm)); - core.getWorkerGateway().registerAdapter(WorkerKind.CODEX_CLI, new CodexCliAdapter(pm)); - core.getWorkerGateway().registerAdapter(WorkerKind.CUSTOM, new CustomShellAdapter(pm)); -} - -let shuttingDown = false; - -async function shutdown(reason: string): Promise { - if (shuttingDown) return; - shuttingDown = true; - logger.info(`Shutting down: ${reason}`); - if (keepaliveTimer) { - clearInterval(keepaliveTimer); - } - try { - await core.shutdown(); - } catch (err) { - logger.error("Error during shutdown", err); - } - process.exit(0); -} - -process.on("SIGTERM", () => shutdown("SIGTERM")); -process.on("SIGINT", () => shutdown("SIGINT")); - -process.on("uncaughtException", (err) => { - logger.error("Uncaught exception", { name: err.name, message: err.message, stack: err.stack }); - shutdown("uncaughtException").catch(() => process.exit(1)); -}); - -process.on("unhandledRejection", (reason) => { - logger.error("Unhandled rejection", reason); - shutdown("unhandledRejection").catch(() => process.exit(1)); -}); - -logger.info("AgentCore starting"); -core.start(); -logger.info("AgentCore started, awaiting IPC messages", { - transport: ipcSocket ? (ipcSocketKind === "tcp" ? "tcp" : "socket") : "stdio", - socketPath: ipcSocketKind === "unix" ? (ipcSocketPath || undefined) : undefined, - socketHost: ipcSocketKind === "tcp" ? resolvedIpcHost : undefined, - socketPort: ipcSocketKind === "tcp" ? resolvedIpcPort : undefined, -}); +startCoreRuntime(); diff --git a/src/scheduler/supervisor.ts b/src/scheduler/supervisor.ts index 128fcc5..dac125d 100644 --- a/src/scheduler/supervisor.ts +++ b/src/scheduler/supervisor.ts @@ -11,6 +11,12 @@ import { mkdtemp, rm } from "node:fs/promises"; import { Readable, Writable } from "node:stream"; export interface SupervisorConfig { + /** + * Core entrypoint. + * + * - If this ends with .ts/.js, Supervisor spawns: `bun ` + * - Otherwise, Supervisor spawns: `` (as an executable on PATH or a file path) + */ coreEntryPoint: string; healthCheck?: Partial; ipc?: IpcProtocolOptions; @@ -76,10 +82,22 @@ export class Supervisor { return this.spawnCoreStdio(childEnv); } + private resolveCoreCommand(): { command: string; args: string[] } { + const entry = this.config.coreEntryPoint; + const lower = entry.toLowerCase(); + // Treat TS/JS entrypoints as Bun scripts. + if (lower.endsWith(".ts") || lower.endsWith(".js") || lower.endsWith(".tsx") || lower.endsWith(".jsx")) { + return { command: "bun", args: [entry] }; + } + // Otherwise spawn as an executable. + return { command: entry, args: [] }; + } + private async spawnCoreStdio(childEnv: Record): Promise { // Use node:child_process.spawn rather than Bun.spawn for Core IPC. // Some environments exhibit dropped/undelivered stdin data with Bun.spawn. - const proc = nodeSpawn("bun", [this.config.coreEntryPoint], { + const core = this.resolveCoreCommand(); + const proc = nodeSpawn(core.command, core.args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv, }) as unknown as ChildProcessWithoutNullStreams; @@ -88,10 +106,10 @@ export class Supervisor { throw new Error("Failed to spawn Core process: missing stdio handles"); } - if (process.env.AGENTCORE_IPC_TRACE === "1") { + if ((process.env.ROBOPPI_IPC_TRACE ?? process.env.AGENTCORE_IPC_TRACE) === "1") { try { process.stderr.write( - `[IPC][spawn] impl=node_child_process transport=stdio core pid=${proc.pid} entry=${this.config.coreEntryPoint}\n`, + `[IPC][spawn] impl=node_child_process transport=stdio core pid=${proc.pid} cmd=${core.command} args=${core.args.join(" ") || "(none)"} entry=${this.config.coreEntryPoint}\n`, ); } catch { // ignore @@ -218,7 +236,7 @@ export class Supervisor { } catch (err) { // Some sandboxed environments disallow Unix domain sockets entirely (listen EPERM/EACCES). if (isUnixSocketListenUnsupported(err)) { - if (process.env.AGENTCORE_IPC_TRACE === "1") { + if ((process.env.ROBOPPI_IPC_TRACE ?? process.env.AGENTCORE_IPC_TRACE) === "1") { try { const code = getErrCode(err) ?? "unknown"; process.stderr.write( @@ -237,9 +255,13 @@ export class Supervisor { // Ensure Core selects the Unix socket path mode. delete childEnv.AGENTCORE_IPC_SOCKET_HOST; delete childEnv.AGENTCORE_IPC_SOCKET_PORT; + delete childEnv.ROBOPPI_IPC_SOCKET_HOST; + delete childEnv.ROBOPPI_IPC_SOCKET_PORT; + childEnv.ROBOPPI_IPC_SOCKET_PATH = socketPath; childEnv.AGENTCORE_IPC_SOCKET_PATH = socketPath; - const proc = nodeSpawn("bun", [this.config.coreEntryPoint], { + const core = this.resolveCoreCommand(); + const proc = nodeSpawn(core.command, core.args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv, }) as unknown as ChildProcessWithoutNullStreams; @@ -248,10 +270,10 @@ export class Supervisor { throw new Error("Failed to spawn Core process: missing stdio handles"); } - if (process.env.AGENTCORE_IPC_TRACE === "1") { + if ((process.env.ROBOPPI_IPC_TRACE ?? process.env.AGENTCORE_IPC_TRACE) === "1") { try { process.stderr.write( - `[IPC][spawn] impl=node_child_process transport=socket core pid=${proc.pid} entry=${this.config.coreEntryPoint} socket=${socketPath}\n`, + `[IPC][spawn] impl=node_child_process transport=socket core pid=${proc.pid} cmd=${core.command} args=${core.args.join(" ") || "(none)"} entry=${this.config.coreEntryPoint} socket=${socketPath}\n`, ); } catch { // ignore @@ -363,10 +385,14 @@ export class Supervisor { // Ensure Core selects the TCP mode. delete childEnv.AGENTCORE_IPC_SOCKET_PATH; + delete childEnv.ROBOPPI_IPC_SOCKET_PATH; + childEnv.ROBOPPI_IPC_SOCKET_HOST = addr.address || host; + childEnv.ROBOPPI_IPC_SOCKET_PORT = String(addr.port); childEnv.AGENTCORE_IPC_SOCKET_HOST = addr.address || host; childEnv.AGENTCORE_IPC_SOCKET_PORT = String(addr.port); - const proc = nodeSpawn("bun", [this.config.coreEntryPoint], { + const core = this.resolveCoreCommand(); + const proc = nodeSpawn(core.command, core.args, { stdio: ["pipe", "pipe", "pipe"], env: childEnv, }) as unknown as ChildProcessWithoutNullStreams; @@ -375,10 +401,10 @@ export class Supervisor { throw new Error("Failed to spawn Core process: missing stdio handles"); } - if (process.env.AGENTCORE_IPC_TRACE === "1") { + if ((process.env.ROBOPPI_IPC_TRACE ?? process.env.AGENTCORE_IPC_TRACE) === "1") { try { process.stderr.write( - `[IPC][spawn] impl=node_child_process transport=tcp core pid=${proc.pid} entry=${this.config.coreEntryPoint} addr=${addr.address}:${addr.port}\n`, + `[IPC][spawn] impl=node_child_process transport=tcp core pid=${proc.pid} cmd=${core.command} args=${core.args.join(" ") || "(none)"} entry=${this.config.coreEntryPoint} addr=${addr.address}:${addr.port}\n`, ); } catch { // ignore @@ -638,6 +664,7 @@ function collectChildEnv(): Record { function resolveSupervisedIpcTransport(config: SupervisorConfig): "stdio" | "socket" | "tcp" { const raw = ( config.ipcTransport ?? + process.env.ROBOPPI_SUPERVISED_IPC_TRANSPORT ?? process.env.AGENTCORE_SUPERVISED_IPC_TRANSPORT ?? "stdio" ).toLowerCase(); diff --git a/src/types/index.ts b/src/types/index.ts index da5ae3d..4a1fcb9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,3 +5,4 @@ export * from "./worker-task.js"; export * from "./worker-result.js"; export * from "./escalation.js"; export * from "./ipc-messages.js"; +export * from "./worker-task-job-payload.js"; diff --git a/src/types/worker-task-job-payload.ts b/src/types/worker-task-job-payload.ts new file mode 100644 index 0000000..186838e --- /dev/null +++ b/src/types/worker-task-job-payload.ts @@ -0,0 +1,75 @@ +import type { UUID, Timestamp } from "./common.js"; +import { WorkerKind, WorkerCapability, OutputMode } from "./worker-task.js"; + +/** + * Payload shape for jobs of type WORKER_TASK submitted via Core IPC. + * Mirrors the object built by `CoreIpcStepRunner.buildWorkerJob()`. + */ +export interface WorkerTaskJobPayload { + workerTaskId: UUID; + workerKind: WorkerKind; + workspaceRef: string; + instructions: string; + model?: string; + capabilities: WorkerCapability[]; + outputMode: OutputMode; + budget: { + deadlineAt: Timestamp; + maxSteps?: number; + maxCommandTimeMs?: number; + }; + env?: Record; +} + +// Allowed enum string values, derived once at module load. +const VALID_WORKER_KINDS: ReadonlySet = new Set(Object.values(WorkerKind)); +const VALID_CAPABILITIES: ReadonlySet = new Set(Object.values(WorkerCapability)); +const VALID_OUTPUT_MODES: ReadonlySet = new Set(Object.values(OutputMode)); + +function isFiniteNumber(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v); +} + +/** Runtime type guard for {@link WorkerTaskJobPayload}. */ +export function isWorkerTaskJobPayload(value: unknown): value is WorkerTaskJobPayload { + if (value === null || value === undefined || typeof value !== "object") { + return false; + } + + const v = value as Record; + + if (typeof v.workerTaskId !== "string") return false; + if (typeof v.workspaceRef !== "string") return false; + if (typeof v.instructions !== "string") return false; + + // workerKind must be a known enum value + if (typeof v.workerKind !== "string" || !VALID_WORKER_KINDS.has(v.workerKind)) return false; + + // outputMode must be a known enum value + if (typeof v.outputMode !== "string" || !VALID_OUTPUT_MODES.has(v.outputMode)) return false; + + // capabilities must be an array of known enum values + if (!Array.isArray(v.capabilities)) return false; + for (const cap of v.capabilities) { + if (typeof cap !== "string" || !VALID_CAPABILITIES.has(cap)) return false; + } + + // budget + if (v.budget === null || v.budget === undefined || typeof v.budget !== "object") { + return false; + } + const budget = v.budget as Record; + if (!isFiniteNumber(budget.deadlineAt)) return false; + if (budget.maxSteps !== undefined && !isFiniteNumber(budget.maxSteps)) return false; + if (budget.maxCommandTimeMs !== undefined && !isFiniteNumber(budget.maxCommandTimeMs)) return false; + + // env — if present, must be Record + if (v.env !== undefined) { + if (v.env === null || typeof v.env !== "object" || Array.isArray(v.env)) return false; + for (const val of Object.values(v.env as Record)) { + if (typeof val !== "string") return false; + } + } + + return true; +} diff --git a/src/workflow/agent-catalog.ts b/src/workflow/agent-catalog.ts new file mode 100644 index 0000000..a3156c4 --- /dev/null +++ b/src/workflow/agent-catalog.ts @@ -0,0 +1,175 @@ +import YAML from "yaml"; + +import type { StepDefinition, DurationString } from "./types.js"; + +export type AgentWorkerKind = StepDefinition["worker"]; +export type AgentCapability = StepDefinition["capabilities"][number]; + +export interface AgentProfile { + description?: string; + + /** Default worker kind when referenced via step.agent. */ + worker?: AgentWorkerKind; + + /** Default model identifier (adapter-specific). */ + model?: string; + + /** Optional instructions prepended before step.instructions. */ + base_instructions?: string; + + /** Default capabilities when step.capabilities is omitted. */ + capabilities?: AgentCapability[]; + + /** Default workspace (resolved from workflow workspace). */ + workspace?: string; + + /** Default step timeout (DurationString). */ + timeout?: DurationString; + + /** Default max steps for the worker. */ + max_steps?: number; + + /** Default per-command timeout (DurationString). */ + max_command_time?: DurationString; +} + +export type AgentCatalog = Record; + +export class AgentCatalogParseError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentCatalogParseError"; + } +} + +const VALID_WORKERS = new Set([ + "CODEX_CLI", + "CLAUDE_CODE", + "OPENCODE", + "CUSTOM", +]); + +const VALID_CAPABILITIES = new Set([ + "READ", + "EDIT", + "RUN_TESTS", + "RUN_COMMANDS", +]); + +function assertObject(value: unknown, field: string): asserts value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new AgentCatalogParseError(`"${field}" must be an object`); + } +} + +function validateOptionalNonEmptyString(value: unknown, field: string): void { + if (value === undefined) return; + if (typeof value !== "string" || value.trim() === "") { + throw new AgentCatalogParseError(`"${field}" must be a non-empty string`); + } +} + +function validateOptionalString(value: unknown, field: string): void { + if (value === undefined) return; + if (typeof value !== "string") { + throw new AgentCatalogParseError(`"${field}" must be a string`); + } +} + +function validateOptionalWorker(value: unknown, field: string): void { + if (value === undefined) return; + if (typeof value !== "string" || !VALID_WORKERS.has(value as AgentWorkerKind)) { + throw new AgentCatalogParseError( + `"${field}" must be one of: ${[...VALID_WORKERS].join(", ")} (got "${String(value)}")`, + ); + } +} + +function validateOptionalCapabilities(value: unknown, field: string): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + throw new AgentCatalogParseError(`"${field}" must be an array`); + } + for (const cap of value) { + if (typeof cap !== "string" || !VALID_CAPABILITIES.has(cap as AgentCapability)) { + throw new AgentCatalogParseError( + `"${field}" contains invalid capability "${String(cap)}". Valid values: ${[...VALID_CAPABILITIES].join(", ")}`, + ); + } + } +} + +function validateOptionalNumber(value: unknown, field: string, opts?: { min?: number }): void { + if (value === undefined) return; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new AgentCatalogParseError(`"${field}" must be a finite number`); + } + if (opts?.min !== undefined && value < opts.min) { + throw new AgentCatalogParseError(`"${field}" must be >= ${opts.min}`); + } +} + +const MAX_YAML_SIZE = 1024 * 1024; // 1MB + +/** + * Parse an agent catalog YAML. + * + * Format: + * + * version: "1" + * agents: + * : + * worker: OPENCODE + * model: openai/gpt-5.2 + * base_instructions: | + * ... + * capabilities: [READ] + */ +export function parseAgentCatalog(yamlContent: string): AgentCatalog { + if (yamlContent.length > MAX_YAML_SIZE) { + throw new AgentCatalogParseError( + `YAML content too large: ${yamlContent.length} bytes (max ${MAX_YAML_SIZE})`, + ); + } + + let parsed: unknown; + try { + parsed = YAML.parse(yamlContent); + } catch (e) { + throw new AgentCatalogParseError(`Invalid YAML: ${e instanceof Error ? e.message : String(e)}`); + } + + assertObject(parsed, "root"); + const doc = parsed as Record; + + if (doc["version"] !== "1") { + throw new AgentCatalogParseError(`"version" must be "1" (got "${String(doc["version"])}")`); + } + + assertObject(doc["agents"], "agents"); + const agentsObj = doc["agents"] as Record; + + const catalog: AgentCatalog = {}; + for (const [agentId, raw] of Object.entries(agentsObj)) { + if (agentId.trim() === "") { + throw new AgentCatalogParseError(`"agents" must not contain an empty key`); + } + assertObject(raw, `agents.${agentId}`); + const obj = raw as Record; + + validateOptionalNonEmptyString(obj["description"], `agents.${agentId}.description`); + validateOptionalWorker(obj["worker"], `agents.${agentId}.worker`); + validateOptionalNonEmptyString(obj["model"], `agents.${agentId}.model`); + // Allow empty string to intentionally clear inherited base_instructions. + validateOptionalString(obj["base_instructions"], `agents.${agentId}.base_instructions`); + validateOptionalCapabilities(obj["capabilities"], `agents.${agentId}.capabilities`); + validateOptionalNonEmptyString(obj["workspace"], `agents.${agentId}.workspace`); + validateOptionalNonEmptyString(obj["timeout"], `agents.${agentId}.timeout`); + validateOptionalNumber(obj["max_steps"], `agents.${agentId}.max_steps`, { min: 1 }); + validateOptionalNonEmptyString(obj["max_command_time"], `agents.${agentId}.max_command_time`); + + catalog[agentId] = obj as unknown as AgentProfile; + } + + return catalog; +} diff --git a/src/workflow/branch-context.ts b/src/workflow/branch-context.ts new file mode 100644 index 0000000..693f92f --- /dev/null +++ b/src/workflow/branch-context.ts @@ -0,0 +1,324 @@ +import path from "node:path"; + +export type EffectiveBaseBranchSource = "cli" | "env" | "current"; +export type ProtectedBranchesSource = "default" | "env" | "cli"; + +export interface ResolveBranchRuntimeContextInput { + workspaceDir: string; + cliBaseBranch?: string; + envBaseBranch?: string; + cliProtectedBranches?: string; + envProtectedBranches?: string; + cliAllowProtectedBranch?: boolean; + envAllowProtectedBranch?: string; + createBranch?: boolean; + expectedWorkBranch?: string; + branchTransitionStep?: string; + stepIds?: string[]; +} + +export interface BranchRuntimeContext { + enabled: boolean; + createBranch: boolean; + expectedWorkBranch?: string; + expectedCurrentBranch?: string; + branchTransitionStep?: string; + + startupToplevel?: string; + startupBranch?: string; + startupHeadSha?: string; + + effectiveBaseBranch?: string; + effectiveBaseBranchSource?: EffectiveBaseBranchSource; + effectiveBaseSha?: string; + + protectedBranches: string[]; + protectedBranchesSource: ProtectedBranchesSource; + allowProtectedBranch: boolean; + + warnings: string[]; +} + +const DEFAULT_PROTECTED_BRANCHES = ["main", "master", "release/*"]; + +export function toBranchWorkflowMeta(context: BranchRuntimeContext): Record { + return { + create_branch: context.createBranch, + expected_work_branch: context.expectedWorkBranch, + expected_current_branch: context.expectedCurrentBranch, + startup_toplevel: context.startupToplevel, + startup_branch: context.startupBranch, + startup_head_sha: context.startupHeadSha, + effective_base_branch: context.effectiveBaseBranch, + effective_base_branch_source: context.effectiveBaseBranchSource, + effective_base_sha: context.effectiveBaseSha, + protected_branches: context.protectedBranches, + protected_branches_source: context.protectedBranchesSource, + allow_protected_branch: context.allowProtectedBranch, + branch_lock_enabled: context.enabled, + }; +} + +export async function resolveBranchRuntimeContext( + input: ResolveBranchRuntimeContextInput, +): Promise { + const workspaceDir = path.resolve(input.workspaceDir); + const createBranch = input.createBranch ?? false; + const expectedWorkBranchOverride = normalizeOptionalString(input.expectedWorkBranch); + + const protectedResolved = resolveProtectedBranches( + normalizeOptionalString(input.cliProtectedBranches), + normalizeOptionalString(input.envProtectedBranches), + ); + const allowProtectedBranch = + input.cliAllowProtectedBranch === true || + parseEnvBool(input.envAllowProtectedBranch) === true; + + const branchTransitionStep = resolveTransitionStep( + createBranch, + normalizeOptionalString(input.branchTransitionStep), + input.stepIds ?? [], + ); + + const gitDir = await runCommand(workspaceDir, ["git", "rev-parse", "--git-dir"]); + if (gitDir.code !== 0) { + return { + enabled: false, + createBranch, + expectedWorkBranch: expectedWorkBranchOverride, + expectedCurrentBranch: expectedWorkBranchOverride, + branchTransitionStep, + protectedBranches: protectedResolved.protectedBranches, + protectedBranchesSource: protectedResolved.source, + allowProtectedBranch, + warnings: ["branch lock disabled: workspace is not a git repository"], + }; + } + + const startupToplevel = await runGitOrThrow( + workspaceDir, + ["rev-parse", "--show-toplevel"], + "failed to resolve startup_toplevel", + ); + const startupBranch = await runGitOrThrow( + workspaceDir, + ["rev-parse", "--abbrev-ref", "HEAD"], + "failed to resolve startup_branch", + ); + const startupHeadSha = await runGitOrThrow( + workspaceDir, + ["rev-parse", "HEAD"], + "failed to resolve startup_head_sha", + ); + + const warnings: string[] = []; + if (allowProtectedBranch) { + warnings.push("allow_protected_branch=true (protected branch guard is disabled)"); + } + + const cliBaseBranch = normalizeOptionalString(input.cliBaseBranch); + const envBaseBranch = normalizeOptionalString(input.envBaseBranch); + let effectiveBaseBranchSource: EffectiveBaseBranchSource; + let effectiveBaseBranch: string; + if (cliBaseBranch) { + effectiveBaseBranchSource = "cli"; + effectiveBaseBranch = cliBaseBranch; + } else if (envBaseBranch) { + effectiveBaseBranchSource = "env"; + effectiveBaseBranch = envBaseBranch; + } else { + if (startupBranch === "HEAD") { + throw new Error( + "detached HEAD is not allowed without explicit BASE_BRANCH/--base-branch", + ); + } + effectiveBaseBranchSource = "current"; + effectiveBaseBranch = startupBranch; + } + + const effectiveBaseSha = await runGitOrThrow( + workspaceDir, + ["rev-parse", `${effectiveBaseBranch}^{commit}`], + `failed to resolve effective_base_sha from base branch "${effectiveBaseBranch}"`, + ); + + if ( + effectiveBaseBranchSource !== "current" && + startupBranch !== "HEAD" && + startupBranch !== effectiveBaseBranch + ) { + warnings.push( + `BASE_BRANCH override active: base="${effectiveBaseBranch}", startup_branch="${startupBranch}"`, + ); + } + + const expectedWorkBranch = expectedWorkBranchOverride ?? startupBranch; + const expectedCurrentBranch = expectedWorkBranch; + + if ( + createBranch === false && + expectedWorkBranch !== undefined && + isBranchProtected(expectedWorkBranch, protectedResolved.protectedBranches) && + !allowProtectedBranch + ) { + throw new Error( + `blocked: expected_work_branch "${expectedWorkBranch}" is protected ` + + `(protected_branches=${protectedResolved.protectedBranches.join(",")}). ` + + `Use --allow-protected-branch or AGENTCORE_ALLOW_PROTECTED_BRANCH=1 to override.`, + ); + } + + return { + enabled: true, + createBranch, + expectedWorkBranch, + expectedCurrentBranch, + branchTransitionStep, + startupToplevel, + startupBranch, + startupHeadSha, + effectiveBaseBranch, + effectiveBaseBranchSource, + effectiveBaseSha, + protectedBranches: protectedResolved.protectedBranches, + protectedBranchesSource: protectedResolved.source, + allowProtectedBranch, + warnings, + }; +} + +export function isBranchProtected(branch: string, protectedBranches: string[]): boolean { + const target = branch.trim(); + if (!target) return false; + for (const pattern of protectedBranches) { + if (matchesBranchPattern(pattern, target)) return true; + } + return false; +} + +function resolveTransitionStep( + createBranch: boolean, + configured: string | undefined, + stepIds: string[], +): string | undefined { + if (!createBranch) return undefined; + if (configured) return configured; + return stepIds.includes("branch") ? "branch" : undefined; +} + +function resolveProtectedBranches( + cliCsv: string | undefined, + envCsv: string | undefined, +): { protectedBranches: string[]; source: ProtectedBranchesSource } { + if (cliCsv) { + return { + protectedBranches: parseCsvBranches(cliCsv, "cli"), + source: "cli", + }; + } + if (envCsv) { + return { + protectedBranches: parseCsvBranches(envCsv, "env"), + source: "env", + }; + } + return { + protectedBranches: [...DEFAULT_PROTECTED_BRANCHES], + source: "default", + }; +} + +function parseCsvBranches(csv: string, source: "cli" | "env"): string[] { + const parts = csv + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + if (parts.length === 0) { + throw new Error(`protected branches ${source} value is empty`); + } + return parts; +} + +function matchesBranchPattern(pattern: string, branch: string): boolean { + const p = pattern.trim(); + if (!p) return false; + if (!p.includes("*")) { + return branch === p; + } + const re = globToRegExp(p); + return re.test(branch); +} + +function globToRegExp(pattern: string): RegExp { + let re = "^"; + for (const c of pattern) { + if (c === "*") { + re += ".*"; + continue; + } + if (/[\\^$.*+?()[\]{}|]/.test(c)) { + re += `\\${c}`; + continue; + } + re += c; + } + re += "$"; + return new RegExp(re); +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function parseEnvBool(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") { + return false; + } + return undefined; +} + +async function runGitOrThrow( + cwd: string, + args: string[], + context: string, +): Promise { + const out = await runCommand(cwd, ["git", ...args]); + if (out.code !== 0) { + const detail = out.stderr.trim() || out.stdout.trim() || `exit=${out.code}`; + throw new Error(`${context}: ${detail}`); + } + const trimmed = out.stdout.trim(); + if (!trimmed) { + throw new Error(`${context}: empty output`); + } + return trimmed; +} + +async function runCommand( + cwd: string, + command: string[], +): Promise<{ code: number; stdout: string; stderr: string }> { + try { + const proc = Bun.spawn(command, { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { code, stdout, stderr }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { code: 127, stdout: "", stderr: msg }; + } +} diff --git a/src/workflow/completion-decision.ts b/src/workflow/completion-decision.ts index 83e3037..ef12a30 100644 --- a/src/workflow/completion-decision.ts +++ b/src/workflow/completion-decision.ts @@ -25,7 +25,11 @@ export interface CompletionDecisionResolution { fingerprints?: string[]; } +// Completion-check correlation id. +// +// Legacy name is kept for compatibility; the runner also sets ROBOPPI_COMPLETION_CHECK_ID. export const COMPLETION_CHECK_ID_ENV = "AGENTCORE_COMPLETION_CHECK_ID"; +export const COMPLETION_CHECK_ID_ENV_ROBOPPI = "ROBOPPI_COMPLETION_CHECK_ID"; const STALE_DECISION_FILE_GRACE_MS = 2_000; const RE_COMPLETE = /\bCOMPLETE\b/i; diff --git a/src/workflow/context-manager.ts b/src/workflow/context-manager.ts index c330a47..52504c6 100644 --- a/src/workflow/context-manager.ts +++ b/src/workflow/context-manager.ts @@ -16,6 +16,35 @@ export interface StepMeta { workerResult?: unknown; } +/** + * Resolved execution parameters written to `_resolved.json` for + * observability / debugging. Kept separate from {@link StepMeta} so + * that the two files can evolve independently and concurrent writers + * do not stomp each other. + */ +export interface StepResolvedMeta { + stepId: string; + status: string; + startedAt: number; + completedAt?: number; + wallTimeMs?: number; + attempts: number; + iterations?: number; + maxIterations?: number; + workerKind: string; + artifacts: Array<{ name: string; path: string; type?: string }>; + workerResult?: unknown; + resolved: { + timeoutMs?: number; + workspaceRef?: string; + workerKind?: string; + model?: string; + capabilities?: string[]; + maxSteps?: number; + maxCommandTimeMs?: number; + }; +} + function validatePathSegment(name: string): void { if (name.includes("..") || name.includes(path.sep) || name.includes("/")) { throw new Error(`Invalid path segment: ${name}`); @@ -43,6 +72,7 @@ export class ContextManager { workflowId: string, workflowName: string, startedAt: number = Date.now(), + metadata?: Record, ): Promise { await mkdir(this.contextDir, { recursive: true }); const workflowMeta = { @@ -50,6 +80,7 @@ export class ContextManager { name: workflowName, startedAt, status: "RUNNING", + ...(metadata ?? {}), }; await writeFile( path.join(this.contextDir, "_workflow.json"), @@ -106,6 +137,12 @@ export class ContextManager { await writeFile(metaPath, JSON.stringify(meta, null, 2)); } + async writeStepResolved(stepId: string, meta: StepResolvedMeta): Promise { + const dir = path.join(this.contextDir, stepId); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "_resolved.json"), JSON.stringify(meta, null, 2)); + } + async readStepMeta(stepId: string): Promise { const metaPath = path.join(this.contextDir, stepId, "_meta.json"); try { diff --git a/src/workflow/core-ipc-step-runner.ts b/src/workflow/core-ipc-step-runner.ts index c3c4edf..9925b98 100644 --- a/src/workflow/core-ipc-step-runner.ts +++ b/src/workflow/core-ipc-step-runner.ts @@ -1,13 +1,12 @@ -import path from "node:path"; - import type { StepDefinition, CompletionCheckDef } from "./types.js"; import type { StepRunner, StepRunResult, CheckResult } from "./executor.js"; -import { parseDuration } from "./duration.js"; import { extractWorkerText, resolveCompletionDecision, COMPLETION_CHECK_ID_ENV, + COMPLETION_CHECK_ID_ENV_ROBOPPI, } from "./completion-decision.js"; +import { resolveTaskLike, type ResolvedWorkerTaskDef } from "./resolve-worker-task.js"; import { Supervisor } from "../scheduler/supervisor.js"; import type { IpcProtocol } from "../ipc/protocol.js"; @@ -21,10 +20,9 @@ import type { WorkerResult, PermitRejection, UUID, + WorkerTaskJobPayload, } from "../types/index.js"; import { - WorkerKind, - WorkerCapability, OutputMode, WorkerStatus, JobType, @@ -34,40 +32,6 @@ import { ErrorClass, } from "../types/index.js"; -type StepWorker = StepDefinition["worker"]; - -type WorkerTaskDef = { - workspace?: string; - instructions: string; - capabilities: StepDefinition["capabilities"]; - timeout?: StepDefinition["timeout"]; - max_steps?: StepDefinition["max_steps"]; - max_command_time?: StepDefinition["max_command_time"]; - model?: StepDefinition["model"]; -}; - -type ResolvedWorkerTaskDef = WorkerTaskDef & { - workspaceRef: string; - env?: Record; - timeoutMs: number; - maxCommandTimeMs?: number; -}; - -const DEFAULT_STEP_TIMEOUT_MS = 24 * 60 * 60 * 1000; - -function toWorkerKind(worker: StepWorker): WorkerKind { - switch (worker) { - case "CUSTOM": - return WorkerKind.CUSTOM; - case "OPENCODE": - return WorkerKind.OPENCODE; - case "CLAUDE_CODE": - return WorkerKind.CLAUDE_CODE; - case "CODEX_CLI": - return WorkerKind.CODEX_CLI; - } -} - function isPermitGranted( msg: PermitGrantedMessage | PermitRejectedMessage, ): msg is PermitGrantedMessage { @@ -92,7 +56,6 @@ export class CoreIpcStepRunner implements StepRunner { private ipc: IpcProtocol | null = null; private readonly waiters = new Map void }>(); - private readonly buffered = new Map(); constructor(options: CoreIpcStepRunnerOptions = {}) { this.verbose = options.verbose ?? false; @@ -120,7 +83,6 @@ export class CoreIpcStepRunner implements StepRunner { } this.ipc = null; this.waiters.clear(); - this.buffered.clear(); } async runStep( @@ -130,10 +92,9 @@ export class CoreIpcStepRunner implements StepRunner { abortSignal: AbortSignal, env?: Record, ): Promise { - const workerKind = toWorkerKind(step.worker); - const task = this.buildWorkerTaskDef(step, workspaceDir, env); + const task = resolveTaskLike(step, workspaceDir, env); - const result = await this.runWorkerTask(stepId, workerKind, task, abortSignal); + const result = await this.runWorkerTask(stepId, task, abortSignal); if (result.status === WorkerStatus.SUCCEEDED) { return { @@ -166,18 +127,15 @@ export class CoreIpcStepRunner implements StepRunner { ): Promise { const checkStartedAt = Date.now(); const checkId = generateId(); - const workerKind = toWorkerKind(check.worker); - const task = this.buildWorkerTaskDef( - check, - workspaceDir, - check.decision_file - ? { - ...(env ?? {}), - [COMPLETION_CHECK_ID_ENV]: checkId, - } - : env, - ); - const result = await this.runWorkerTask(stepId, workerKind, task, abortSignal); + const checkEnv = check.decision_file + ? { + ...(env ?? {}), + [COMPLETION_CHECK_ID_ENV]: checkId, + [COMPLETION_CHECK_ID_ENV_ROBOPPI]: checkId, + } + : env; + const task = resolveTaskLike(check, workspaceDir, checkEnv); + const result = await this.runWorkerTask(stepId, task, abortSignal); if (check.worker === "CUSTOM") { // Shell completion check semantics: @@ -250,27 +208,6 @@ export class CoreIpcStepRunner implements StepRunner { }; } - private buildWorkerTaskDef( - def: WorkerTaskDef, - workspaceDir: string, - env?: Record, - ): ResolvedWorkerTaskDef { - const workspaceRef = def.workspace - ? path.resolve(workspaceDir, def.workspace) - : workspaceDir; - - const timeoutMs = def.timeout ? parseDuration(def.timeout) : DEFAULT_STEP_TIMEOUT_MS; - const maxCommandTimeMs = def.max_command_time ? parseDuration(def.max_command_time) : undefined; - - return { - ...def, - workspaceRef, - env, - timeoutMs, - ...(maxCommandTimeMs !== undefined ? { maxCommandTimeMs } : {}), - }; - } - private async ensureIpc(): Promise { if (this.ipc) return this.ipc; const ipc = await this.supervisor.spawnCore(); @@ -288,16 +225,17 @@ export class CoreIpcStepRunner implements StepRunner { waiter.resolve(msg); return; } - this.buffered.set(msg.jobId, msg); + // No waiter means the caller already returned (abort / timeout). + // Drop the message — the waiter cleanup in runWorkerTask's finally + // block guarantees no legitimate waiter is missing. + if (this.verbose) { + process.stderr.write( + `[ipc] dropped late job_completed: jobId=${msg.jobId} outcome=${msg.outcome}\n`, + ); + } } private waitForJobCompleted(jobId: UUID): Promise { - const buffered = this.buffered.get(jobId); - if (buffered) { - this.buffered.delete(jobId); - return Promise.resolve(buffered); - } - return new Promise((resolve) => { this.waiters.set(jobId, { resolve }); }); @@ -305,7 +243,6 @@ export class CoreIpcStepRunner implements StepRunner { private async runWorkerTask( stepId: string, - workerKind: WorkerKind, task: ResolvedWorkerTaskDef, parentAbort: AbortSignal, ): Promise { @@ -315,7 +252,7 @@ export class CoreIpcStepRunner implements StepRunner { // Step timeout should apply to the worker execution budget, not to Core startup. // We'll compute the real deadline after submit_job is acknowledged. const placeholderDeadlineAt = Date.now() + task.timeoutMs; - const job = this.buildWorkerJob(workerKind, { ...task, deadlineAt: placeholderDeadlineAt }); + const job = this.buildWorkerJob({ ...task, deadlineAt: placeholderDeadlineAt }); const completionPromise = this.waitForJobCompleted(job.jobId); const cancelReason = `step:${stepId} aborted`; @@ -346,7 +283,8 @@ export class CoreIpcStepRunner implements StepRunner { const deadlineAt = Date.now() + task.timeoutMs; // Update job payload budget to reflect the post-ack execution deadline. // (Other budget fields are preserved from the placeholder job.) - (job.payload as any).budget.deadlineAt = deadlineAt; + const payload = job.payload as { budget: { deadlineAt: number } }; + payload.budget.deadlineAt = deadlineAt; await this.requestPermitUntilGranted(ipc, job, deadlineAt, parentAbort); @@ -431,25 +369,27 @@ export class CoreIpcStepRunner implements StepRunner { execScoped.signal.removeEventListener("abort", onExecAbort); } execScoped?.cleanup(); + // Clean up waiter to prevent Map leak when we return without + // receiving a job_completed (e.g. abort, timeout, IPC error). + this.waiters.delete(job.jobId); } } private buildWorkerJob( - workerKind: WorkerKind, task: ResolvedWorkerTaskDef & { deadlineAt: number }, ): Job { const jobId = generateId(); - const payload = { + const payload: WorkerTaskJobPayload = { workerTaskId: generateId(), - workerKind, + workerKind: task.workerKind, workspaceRef: task.workspaceRef, instructions: task.instructions, ...(task.model ? { model: task.model } : {}), - capabilities: task.capabilities.map((c) => WorkerCapability[c]), + capabilities: task.capabilities, outputMode: OutputMode.BATCH, budget: { deadlineAt: task.deadlineAt, - ...(task.max_steps !== undefined ? { maxSteps: task.max_steps } : {}), + ...(task.maxSteps !== undefined ? { maxSteps: task.maxSteps } : {}), ...(task.maxCommandTimeMs !== undefined ? { maxCommandTimeMs: task.maxCommandTimeMs } : {}), }, ...(task.env ? { env: task.env } : {}), @@ -503,6 +443,15 @@ export class CoreIpcStepRunner implements StepRunner { while (true) { if (signal.aborted) throw new StepAbortedError(); + // Bail out if the step deadline has already been exceeded while + // waiting for a permit. Without this check the loop would + // continue indefinitely when permits are slow to arrive. + if (Date.now() >= deadlineAt) { + throw new Error( + `Step deadline exceeded while waiting for permit (jobId=${job.jobId})`, + ); + } + // Keep Core-side permit deadline aligned with the step deadline. job.limits.timeoutMs = Math.max(1, deadlineAt - Date.now()); diff --git a/src/workflow/executor.ts b/src/workflow/executor.ts index ca0933d..53f10d0 100644 --- a/src/workflow/executor.ts +++ b/src/workflow/executor.ts @@ -15,7 +15,10 @@ import { WorkflowStatus, StepStatus } from "./types.js"; import { validateDag } from "./dag-validator.js"; import { parseDuration } from "./duration.js"; import type { ContextManager } from "./context-manager.js"; +import { resolveTaskLike } from "./resolve-worker-task.js"; import type { CompletionDecisionSource } from "./completion-decision.js"; +import type { BranchRuntimeContext } from "./branch-context.js"; +import { toBranchWorkflowMeta } from "./branch-context.js"; // --------------------------------------------------------------------------- // Public interfaces @@ -156,6 +159,7 @@ export class WorkflowExecutor { private readonly workspaceDir: string, private readonly env?: Record, private readonly abortSignal?: AbortSignal, + private readonly branchContext?: BranchRuntimeContext, ) { this.stepDefs = definition.steps; this.concurrency = @@ -179,7 +183,12 @@ export class WorkflowExecutor { // 2. Initialize step states and context const workflowId = crypto.randomUUID(); const startedAt = Date.now(); - await this.contextManager.initWorkflow(workflowId, this.definition.name, startedAt); + await this.contextManager.initWorkflow( + workflowId, + this.definition.name, + startedAt, + this.buildWorkflowMetaExtras(), + ); for (const [stepId, stepDef] of Object.entries(this.stepDefs)) { const maxIter = stepDef.max_iterations ?? 1; this.steps[stepId] = { @@ -240,6 +249,11 @@ export class WorkflowExecutor { startedAt, status: workflowStatus, completedAt, + resolved: { + timeoutMs: this.workflowTimeoutMs, + concurrency: this.concurrency, + }, + ...this.buildWorkflowMetaExtras(), }); } @@ -402,6 +416,7 @@ export class WorkflowExecutor { const state = this.steps[stepId]!; let retryCount = 0; const maxRetries = stepDef.max_retries ?? 0; + let lastStepResult: StepRunResult | undefined; try { // Main loop: handles both completion_check iterations and retries @@ -410,6 +425,15 @@ export class WorkflowExecutor { // If workflow was aborted (timeout), bail out — status already set by handleWorkflowTimeout if (this.workflowAbortController!.signal.aborted) return; + try { + await this.assertBranchLock(stepId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.markStepFailed(stepId, msg); + this.skipDependents(stepId); + return; + } + // Resolve inputs before running the step if (stepDef.inputs && stepDef.inputs.length > 0) { await this.contextManager.resolveInputs(stepId, stepDef.inputs, this.workspaceDir); @@ -430,6 +454,7 @@ export class WorkflowExecutor { stepAbort, this.env, ); + lastStepResult = result; } catch (_err) { if (this.workflowAbortController!.signal.aborted) return; this.markStepFailed(stepId, "Worker execution threw an error"); @@ -479,6 +504,15 @@ export class WorkflowExecutor { return; } + try { + await this.maybeUpdateExpectedBranchAfterTransition(stepId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.markStepFailed(stepId, msg); + this.skipDependents(stepId); + return; + } + const collectOutputsIfDefined = async () => { if (stepDef.outputs && stepDef.outputs.length > 0) { await this.contextManager.collectOutputs(stepId, stepDef.outputs, this.workspaceDir); @@ -500,9 +534,10 @@ export class WorkflowExecutor { const checkAbort = this.createStepAbort(); let checkResult: CheckResult; try { + const effectiveCheck = this.resolveCheckTimeout(stepDef, stepDef.completion_check); checkResult = await this.stepRunner.runCheck( stepId, - stepDef.completion_check, + effectiveCheck, this.workspaceDir, checkAbort, this.env, @@ -621,10 +656,60 @@ export class WorkflowExecutor { } } finally { this.runningCount--; + await this.writeStepResolvedMeta(stepId, state, stepDef, retryCount, lastStepResult); this.notify(); } } + private async writeStepResolvedMeta( + stepId: string, + state: StepState, + stepDef: StepDefinition, + retryCount: number, + lastResult?: StepRunResult, + ): Promise { + try { + const artifacts = (lastResult?.artifacts ?? []).map((a) => ({ + name: a.ref ?? "", + path: a.ref ?? "", + type: a.type, + })); + const baseMeta = { + stepId, + status: state.status, + startedAt: state.startedAt ?? 0, + completedAt: state.completedAt, + wallTimeMs: (state.completedAt ?? Date.now()) - (state.startedAt ?? 0), + attempts: retryCount + 1, + iterations: state.iteration, + maxIterations: state.maxIterations, + workerKind: stepDef.worker, + artifacts, + ...(lastResult?.cost ? { workerResult: { cost: lastResult.cost } } : {}), + }; + + // _meta.json: step execution metadata (status, timing, artifacts) + await this.contextManager.writeStepMeta(stepId, baseMeta); + + // _resolved.json: resolved execution parameters for observability + const resolved = resolveTaskLike(stepDef, this.workspaceDir); + await this.contextManager.writeStepResolved(stepId, { + ...baseMeta, + resolved: { + timeoutMs: resolved.timeoutMs, + workspaceRef: resolved.workspaceRef, + workerKind: resolved.workerKind, + model: resolved.model, + capabilities: resolved.capabilities.map(String), + ...(resolved.maxSteps !== undefined ? { maxSteps: resolved.maxSteps } : {}), + ...(resolved.maxCommandTimeMs !== undefined ? { maxCommandTimeMs: resolved.maxCommandTimeMs } : {}), + }, + }); + } catch { + // Best-effort: don't fail the workflow if metadata writing fails + } + } + private isCompletionInfraFailure(checkResult: CheckResult): boolean { // Signals we treat as infrastructure / decision-channel failures: // - could not determine decision (source none) @@ -936,6 +1021,7 @@ export class WorkflowExecutor { const ignoredPatterns = [ "context/**", ".agentcore-loop/**", + ".roboppi-loop/**", ".git/**", ...(cfg.ignored_paths ?? []), ]; @@ -1121,10 +1207,117 @@ export class WorkflowExecutor { // Helpers // ----------------------------------------------------------------------- + private buildWorkflowMetaExtras(): Record { + if (!this.branchContext) return {}; + return toBranchWorkflowMeta(this.branchContext); + } + + private async assertBranchLock(stepId: string): Promise { + const context = this.branchContext; + if (!context || !context.enabled) return; + + if (!context.startupToplevel) return; + const toplevel = await runCommand(this.workspaceDir, [ + "git", + "rev-parse", + "--show-toplevel", + ]); + if (toplevel.code !== 0) { + const detail = toplevel.stderr.trim() || toplevel.stdout.trim() || `exit=${toplevel.code}`; + throw new Error( + `Branch lock failed before step "${stepId}": cannot resolve current repo toplevel (${detail}).`, + ); + } + const currentToplevel = toplevel.stdout.trim(); + if (currentToplevel !== context.startupToplevel) { + throw new Error( + `Branch drift detected before step "${stepId}": repo toplevel mismatch ` + + `(expected "${context.startupToplevel}", actual "${currentToplevel}"). ` + + `Recovery: run from "${context.startupToplevel}" and retry.`, + ); + } + + const expectedBranch = + context.expectedCurrentBranch ?? + context.expectedWorkBranch ?? + context.startupBranch; + if (!expectedBranch) return; + + const branch = await runCommand(this.workspaceDir, [ + "git", + "rev-parse", + "--abbrev-ref", + "HEAD", + ]); + if (branch.code !== 0) { + const detail = branch.stderr.trim() || branch.stdout.trim() || `exit=${branch.code}`; + throw new Error( + `Branch lock failed before step "${stepId}": cannot resolve current branch (${detail}).`, + ); + } + const currentBranch = branch.stdout.trim(); + if (currentBranch !== expectedBranch) { + throw new Error( + `Branch drift detected before step "${stepId}": expected branch "${expectedBranch}" ` + + `but found "${currentBranch}". Recovery: git checkout "${expectedBranch}" and retry.`, + ); + } + } + + private async maybeUpdateExpectedBranchAfterTransition(stepId: string): Promise { + const context = this.branchContext; + if (!context || !context.enabled) return; + if (!context.createBranch) return; + if (!context.branchTransitionStep) return; + if (stepId !== context.branchTransitionStep) return; + + const branch = await runCommand(this.workspaceDir, [ + "git", + "rev-parse", + "--abbrev-ref", + "HEAD", + ]); + if (branch.code !== 0) { + const detail = branch.stderr.trim() || branch.stdout.trim() || `exit=${branch.code}`; + throw new Error( + `Branch transition failed after step "${stepId}": cannot resolve current branch (${detail}).`, + ); + } + const next = branch.stdout.trim(); + if (!next || next === "HEAD") { + throw new Error( + `Branch transition failed after step "${stepId}": expected a named branch but got "${next || ""}".`, + ); + } + + context.expectedWorkBranch = next; + context.expectedCurrentBranch = next; + } + private createStepAbort(): AbortSignal { // Link to workflow-level abort return this.workflowAbortController!.signal; } + + /** + * If the completion_check has no explicit timeout, derive a default + * from the parent step's timeout (step timeout / 4). This prevents + * a check from silently inheriting the 24 h fallback. + */ + private resolveCheckTimeout( + stepDef: StepDefinition, + check: CompletionCheckDef, + ): CompletionCheckDef { + if (check.timeout) return check; + + const stepTimeoutMs = stepDef.timeout + ? parseDuration(stepDef.timeout) + : this.workflowTimeoutMs; + const checkTimeoutMs = Math.max(1000, Math.floor(stepTimeoutMs / 4)); + + // Express as a plain ms duration string so resolveTaskLike can parse it. + return { ...check, timeout: `${checkTimeoutMs}ms` }; + } } function normalizeSignalList(list: string[] | undefined, limit: number): string[] | undefined { diff --git a/src/workflow/index.ts b/src/workflow/index.ts index 1abbfc8..8544c42 100644 --- a/src/workflow/index.ts +++ b/src/workflow/index.ts @@ -5,3 +5,6 @@ export * from "./dag-validator.js"; export * from "./context-manager.js"; export * from "./executor.js"; export * from "./shell-step-runner.js"; +export * from "./branch-context.js"; +export * from "./agent-catalog.js"; +export * from "./resolve-worker-task.js"; diff --git a/src/workflow/multi-worker-step-runner.ts b/src/workflow/multi-worker-step-runner.ts index 0f942f9..48b6f15 100644 --- a/src/workflow/multi-worker-step-runner.ts +++ b/src/workflow/multi-worker-step-runner.ts @@ -7,16 +7,16 @@ * * This allows workflow YAML to specify real worker kinds directly. */ -import path from "node:path"; import type { StepDefinition, CompletionCheckDef } from "./types.js"; import type { StepRunner, StepRunResult, CheckResult } from "./executor.js"; import { ShellStepRunner } from "./shell-step-runner.js"; -import { parseDuration } from "./duration.js"; import { extractWorkerText, resolveCompletionDecision, COMPLETION_CHECK_ID_ENV, + COMPLETION_CHECK_ID_ENV_ROBOPPI, } from "./completion-decision.js"; +import { resolveTaskLike, buildWorkerTask } from "./resolve-worker-task.js"; import { ProcessManager } from "../worker/process-manager.js"; import type { WorkerAdapter, WorkerEvent } from "../worker/worker-adapter.js"; @@ -25,9 +25,6 @@ import { ClaudeCodeAdapter } from "../worker/adapters/claude-code-adapter.js"; import { CodexCliAdapter } from "../worker/adapters/codex-cli-adapter.js"; import { - WorkerKind, - WorkerCapability, - OutputMode, WorkerStatus, generateId, ErrorClass, @@ -36,27 +33,6 @@ import type { WorkerTask, WorkerResult } from "../types/index.js"; type LlmWorker = Exclude; -type WorkerTaskDef = { - workspace?: string; - instructions: string; - capabilities: StepDefinition["capabilities"]; - timeout?: StepDefinition["timeout"]; - max_steps?: StepDefinition["max_steps"]; - max_command_time?: StepDefinition["max_command_time"]; - model?: StepDefinition["model"]; -}; - -function toWorkerKind(worker: LlmWorker): WorkerKind { - switch (worker) { - case "OPENCODE": - return WorkerKind.OPENCODE; - case "CLAUDE_CODE": - return WorkerKind.CLAUDE_CODE; - case "CODEX_CLI": - return WorkerKind.CODEX_CLI; - } -} - export class MultiWorkerStepRunner implements StepRunner { private readonly shell: ShellStepRunner; private readonly pm: ProcessManager; @@ -91,7 +67,8 @@ export class MultiWorkerStepRunner implements StepRunner { return { status: "FAILED", errorClass: ErrorClass.NON_RETRYABLE }; } - const task = this.buildWorkerTask(step, workspaceDir, abortSignal, env, toWorkerKind(step.worker)); + const resolved = resolveTaskLike(step, workspaceDir, env); + const task = buildWorkerTask(resolved, abortSignal); return this.runViaAdapter(stepId, adapter, task); } @@ -113,18 +90,15 @@ export class MultiWorkerStepRunner implements StepRunner { const checkStartedAt = Date.now(); const checkId = generateId(); - const task = this.buildWorkerTask( - check, - workspaceDir, - abortSignal, - check.decision_file - ? { - ...(env ?? {}), - [COMPLETION_CHECK_ID_ENV]: checkId, - } - : env, - toWorkerKind(check.worker), - ); + const checkEnv = check.decision_file + ? { + ...(env ?? {}), + [COMPLETION_CHECK_ID_ENV]: checkId, + [COMPLETION_CHECK_ID_ENV_ROBOPPI]: checkId, + } + : env; + const resolved = resolveTaskLike(check, workspaceDir, checkEnv); + const task = buildWorkerTask(resolved, abortSignal); const result = await this.runWorkerTask(stepId, adapter, task); if (result.status !== WorkerStatus.SUCCEEDED) { @@ -178,44 +152,6 @@ export class MultiWorkerStepRunner implements StepRunner { }; } - private buildWorkerTask( - def: WorkerTaskDef, - workspaceDir: string, - abortSignal: AbortSignal, - env?: Record, - workerKind: WorkerKind = WorkerKind.CUSTOM, - ): WorkerTask { - const workspaceRef = def.workspace - ? path.resolve(workspaceDir, def.workspace) - : workspaceDir; - - // If a step-level timeout is provided, also set a deadline. - const deadlineAt = def.timeout - ? Date.now() + parseDuration(def.timeout) - : Date.now() + 24 * 60 * 60 * 1000; - - const maxCommandTimeMs = def.max_command_time - ? parseDuration(def.max_command_time) - : undefined; - - return { - workerTaskId: generateId(), - workerKind, - workspaceRef, - instructions: def.instructions, - capabilities: def.capabilities.map((c) => WorkerCapability[c]), - outputMode: OutputMode.BATCH, - ...(def.model ? { model: def.model } : {}), - budget: { - deadlineAt, - ...(def.max_steps !== undefined ? { maxSteps: def.max_steps } : {}), - ...(maxCommandTimeMs !== undefined ? { maxCommandTimeMs } : {}), - }, - env, - abortSignal, - }; - } - private async runViaAdapter( stepId: string, adapter: WorkerAdapter, diff --git a/src/workflow/parser.ts b/src/workflow/parser.ts index 2898146..7e17a28 100644 --- a/src/workflow/parser.ts +++ b/src/workflow/parser.ts @@ -1,5 +1,6 @@ import YAML from "yaml"; import type { WorkflowDefinition, StepDefinition, CompletionCheckDef } from "./types.js"; +import type { AgentCatalog, AgentProfile } from "./agent-catalog.js"; const VALID_WORKERS = new Set(["CODEX_CLI", "CLAUDE_CODE", "OPENCODE", "CUSTOM"]); const VALID_CAPABILITIES = new Set(["READ", "EDIT", "RUN_TESTS", "RUN_COMMANDS"]); @@ -11,6 +12,11 @@ export class WorkflowParseError extends Error { } } +export interface WorkflowParseOptions { + /** Optional agent catalog used to resolve step.agent references. */ + agents?: AgentCatalog; +} + function assertString(value: unknown, field: string): asserts value is string { if (typeof value !== "string" || value === "") { throw new WorkflowParseError(`"${field}" must be a non-empty string`); @@ -108,11 +114,92 @@ function validateCapabilities(value: unknown, field: string): void { } } -function validateCompletionCheck(check: unknown, stepId: string): CompletionCheckDef { +function normalizeAgentId(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new WorkflowParseError(`"${field}" must be a non-empty string`); + } + return value.trim(); +} + +function combineInstructions(base: string | undefined, specific: string | undefined): string | undefined { + const b = (base ?? "").trim(); + const s = (specific ?? "").trim(); + if (b && s) return `${b}\n\n${s}`; + if (s) return s; + if (b) return b; + return undefined; +} + +function resolveTaskLikeWithAgent( + obj: Record, + fieldPrefix: string, + agents?: AgentCatalog, +): Record { + if (obj["agent"] === undefined) return obj; + + const agentId = normalizeAgentId(obj["agent"], `${fieldPrefix}.agent`); + if (!agents) { + throw new WorkflowParseError( + `${fieldPrefix}.agent is set to "${agentId}", but no agent catalog was provided (use --agents or set AGENTCORE_AGENTS_FILE)`, + ); + } + + const agent = agents[agentId] as AgentProfile | undefined; + if (!agent) { + throw new WorkflowParseError(`${fieldPrefix}.agent references unknown agent "${agentId}"`); + } + + const resolved: Record = { ...obj }; + + if (resolved["description"] === undefined && agent.description !== undefined) { + resolved["description"] = agent.description; + } + + if (resolved["worker"] === undefined && agent.worker !== undefined) { + resolved["worker"] = agent.worker; + } + if (resolved["model"] === undefined && agent.model !== undefined) { + resolved["model"] = agent.model; + } + if (resolved["capabilities"] === undefined && agent.capabilities !== undefined) { + resolved["capabilities"] = agent.capabilities; + } + if (resolved["workspace"] === undefined && agent.workspace !== undefined) { + resolved["workspace"] = agent.workspace; + } + if (resolved["timeout"] === undefined && agent.timeout !== undefined) { + resolved["timeout"] = agent.timeout; + } + if (resolved["max_steps"] === undefined && agent.max_steps !== undefined) { + resolved["max_steps"] = agent.max_steps; + } + if (resolved["max_command_time"] === undefined && agent.max_command_time !== undefined) { + resolved["max_command_time"] = agent.max_command_time; + } + + if (agent.base_instructions !== undefined) { + const combined = combineInstructions( + typeof agent.base_instructions === "string" ? agent.base_instructions : undefined, + typeof resolved["instructions"] === "string" ? (resolved["instructions"] as string) : undefined, + ); + if (combined !== undefined) { + resolved["instructions"] = combined; + } + } + + return resolved; +} + +function validateCompletionCheck(check: unknown, stepId: string, agents?: AgentCatalog): CompletionCheckDef { if (typeof check !== "object" || check === null) { throw new WorkflowParseError(`steps.${stepId}.completion_check must be an object`); } - const obj = check as Record; + const obj = resolveTaskLikeWithAgent( + check as Record, + `steps.${stepId}.completion_check`, + agents, + ); + assertString(obj["instructions"], `steps.${stepId}.completion_check.instructions`); validateWorker(obj["worker"], `steps.${stepId}.completion_check.worker`); validateCapabilities(obj["capabilities"], `steps.${stepId}.completion_check.capabilities`); @@ -121,11 +208,15 @@ function validateCompletionCheck(check: unknown, stepId: string): CompletionChec return obj as unknown as CompletionCheckDef; } -function validateStep(stepId: string, step: unknown): StepDefinition { +function validateStep(stepId: string, step: unknown, agents?: AgentCatalog): StepDefinition { if (typeof step !== "object" || step === null) { throw new WorkflowParseError(`steps.${stepId} must be an object`); } - const obj = step as Record; + const obj = resolveTaskLikeWithAgent( + step as Record, + `steps.${stepId}`, + agents, + ); assertString(obj["instructions"], `steps.${stepId}.instructions`); validateWorker(obj["worker"], `steps.${stepId}.worker`); @@ -176,7 +267,7 @@ function validateStep(stepId: string, step: unknown): StepDefinition { // Validate completion_check + max_iterations constraint if (obj["completion_check"] !== undefined) { - validateCompletionCheck(obj["completion_check"], stepId); + obj["completion_check"] = validateCompletionCheck(obj["completion_check"], stepId, agents); const maxIter = obj["max_iterations"]; if (maxIter !== undefined) { if (typeof maxIter !== "number" || maxIter < 2) { @@ -220,7 +311,7 @@ const MAX_YAML_SIZE = 1024 * 1024; // 1MB /** * Parse YAML content into a validated WorkflowDefinition. */ -export function parseWorkflow(yamlContent: string): WorkflowDefinition { +export function parseWorkflow(yamlContent: string, options: WorkflowParseOptions = {}): WorkflowDefinition { if (yamlContent.length > MAX_YAML_SIZE) { throw new WorkflowParseError( `YAML content too large: ${yamlContent.length} bytes (max ${MAX_YAML_SIZE})`, @@ -248,6 +339,9 @@ export function parseWorkflow(yamlContent: string): WorkflowDefinition { } assertString(doc["timeout"], "timeout"); + validateOptionalBoolean(doc["create_branch"], "create_branch"); + validateOptionalString(doc["branch_transition_step"], "branch_transition_step"); + validateOptionalString(doc["expected_work_branch"], "expected_work_branch"); if (typeof doc["steps"] !== "object" || doc["steps"] === null || Array.isArray(doc["steps"])) { throw new WorkflowParseError('"steps" must be an object'); @@ -261,7 +355,16 @@ export function parseWorkflow(yamlContent: string): WorkflowDefinition { const validatedSteps: Record = {}; for (const stepId of stepIds) { - validatedSteps[stepId] = validateStep(stepId, stepsObj[stepId]); + validatedSteps[stepId] = validateStep(stepId, stepsObj[stepId], options.agents); + } + + if (typeof doc["branch_transition_step"] === "string") { + const transitionStep = doc["branch_transition_step"]; + if (!(transitionStep in validatedSteps)) { + throw new WorkflowParseError( + `"branch_transition_step" references unknown step "${transitionStep}"`, + ); + } } return { @@ -271,6 +374,15 @@ export function parseWorkflow(yamlContent: string): WorkflowDefinition { timeout: doc["timeout"] as string, concurrency: typeof doc["concurrency"] === "number" ? doc["concurrency"] : undefined, context_dir: typeof doc["context_dir"] === "string" ? doc["context_dir"] : undefined, + create_branch: typeof doc["create_branch"] === "boolean" ? doc["create_branch"] : undefined, + branch_transition_step: + typeof doc["branch_transition_step"] === "string" + ? doc["branch_transition_step"] + : undefined, + expected_work_branch: + typeof doc["expected_work_branch"] === "string" + ? doc["expected_work_branch"] + : undefined, steps: validatedSteps, }; } diff --git a/src/workflow/resolve-worker-task.ts b/src/workflow/resolve-worker-task.ts new file mode 100644 index 0000000..cf6c383 --- /dev/null +++ b/src/workflow/resolve-worker-task.ts @@ -0,0 +1,172 @@ +/** + * Shared resolver for WorkerTask definitions. + * + * Centralizes normalization that was previously duplicated across + * MultiWorkerStepRunner and CoreIpcStepRunner: + * - DurationString → ms + * - workspace → absolute path + * - capability strings → WorkerCapability enums + * - worker strings → WorkerKind enum + * + * Phase 1 of the domain modeling refactor (see docs/issues/workflow-domain-modeling.ja.md). + */ +import path from "node:path"; +import type { StepDefinition } from "./types.js"; +import { parseDuration } from "./duration.js"; +import { + WorkerKind, + WorkerCapability, + OutputMode, + generateId, +} from "../types/index.js"; +import type { WorkerTask } from "../types/index.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default step timeout when none is specified (24 hours). */ +export const DEFAULT_STEP_TIMEOUT_MS = 24 * 60 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Resolved model +// --------------------------------------------------------------------------- + +/** + * A task-like definition after normalization/resolution. + * + * All duration strings have been parsed to ms, workspace is an absolute path, + * capabilities are enum values, and worker is a WorkerKind enum. + */ +export interface ResolvedWorkerTaskDef { + workerKind: WorkerKind; + workspaceRef: string; + instructions: string; + model?: string; + capabilities: WorkerCapability[]; + timeoutMs: number; + maxSteps?: number; + maxCommandTimeMs?: number; + env?: Record; +} + +// --------------------------------------------------------------------------- +// Input type (common subset of StepDefinition and CompletionCheckDef) +// --------------------------------------------------------------------------- + +type TaskLikeDef = { + worker: StepDefinition["worker"]; + workspace?: string; + instructions: string; + capabilities: StepDefinition["capabilities"]; + timeout?: StepDefinition["timeout"]; + max_steps?: StepDefinition["max_steps"]; + max_command_time?: StepDefinition["max_command_time"]; + model?: StepDefinition["model"]; +}; + +// --------------------------------------------------------------------------- +// Worker / Capability mapping +// --------------------------------------------------------------------------- + +function toWorkerKind(worker: StepDefinition["worker"]): WorkerKind { + switch (worker) { + case "CUSTOM": + return WorkerKind.CUSTOM; + case "OPENCODE": + return WorkerKind.OPENCODE; + case "CLAUDE_CODE": + return WorkerKind.CLAUDE_CODE; + case "CODEX_CLI": + return WorkerKind.CODEX_CLI; + } +} + +function toWorkerCapabilities( + caps: StepDefinition["capabilities"], +): WorkerCapability[] { + return caps.map((c) => WorkerCapability[c]); +} + +// --------------------------------------------------------------------------- +// Resolve +// --------------------------------------------------------------------------- + +/** + * Normalize a task-like definition (step or completion_check) into a + * resolved form suitable for execution. + * + * This is the single source of truth for: + * - Duration parsing (timeout, max_command_time) + * - Workspace path resolution + * - Capability enum mapping + * - Worker kind enum mapping + */ +export function resolveTaskLike( + def: TaskLikeDef, + workspaceDir: string, + env?: Record, +): ResolvedWorkerTaskDef { + const workspaceRef = def.workspace + ? path.resolve(workspaceDir, def.workspace) + : workspaceDir; + + const timeoutMs = def.timeout + ? parseDuration(def.timeout) + : DEFAULT_STEP_TIMEOUT_MS; + + const maxCommandTimeMs = def.max_command_time + ? parseDuration(def.max_command_time) + : undefined; + + return { + workerKind: toWorkerKind(def.worker), + workspaceRef, + instructions: def.instructions, + capabilities: toWorkerCapabilities(def.capabilities), + timeoutMs, + ...(def.model ? { model: def.model } : {}), + ...(def.max_steps !== undefined ? { maxSteps: def.max_steps } : {}), + ...(maxCommandTimeMs !== undefined ? { maxCommandTimeMs } : {}), + ...(env ? { env } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Build WorkerTask (adds runtime fields: deadlineAt, abortSignal, id) +// --------------------------------------------------------------------------- + +/** + * Build a full WorkerTask from a resolved definition. + * + * This sets the execution deadline (`deadlineAt = now + timeoutMs`) and + * generates a unique workerTaskId. Call this at the point of execution, + * not earlier, to ensure the deadline is accurate. + */ +export function buildWorkerTask( + resolved: ResolvedWorkerTaskDef, + abortSignal: AbortSignal, +): WorkerTask { + const deadlineAt = Date.now() + resolved.timeoutMs; + + return { + workerTaskId: generateId(), + workerKind: resolved.workerKind, + workspaceRef: resolved.workspaceRef, + instructions: resolved.instructions, + capabilities: resolved.capabilities, + outputMode: OutputMode.BATCH, + ...(resolved.model ? { model: resolved.model } : {}), + budget: { + deadlineAt, + ...(resolved.maxSteps !== undefined + ? { maxSteps: resolved.maxSteps } + : {}), + ...(resolved.maxCommandTimeMs !== undefined + ? { maxCommandTimeMs: resolved.maxCommandTimeMs } + : {}), + }, + env: resolved.env, + abortSignal, + }; +} diff --git a/src/workflow/run.ts b/src/workflow/run.ts index dfc438f..18b0071 100644 --- a/src/workflow/run.ts +++ b/src/workflow/run.ts @@ -3,7 +3,8 @@ * Workflow Runner CLI * * Usage: - * bun run src/workflow/run.ts [--workspace ] [--verbose] [--supervised] + * roboppi workflow [--workspace ] [--verbose] [--direct] + * (dev) bun run src/workflow/run.ts [--workspace ] [--verbose] [--direct] */ import { readFile } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises"; @@ -11,6 +12,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseWorkflow } from "./parser.js"; +import { parseAgentCatalog, type AgentCatalog } from "./agent-catalog.js"; import { validateDag } from "./dag-validator.js"; import { ContextManager } from "./context-manager.js"; import { WorkflowExecutor } from "./executor.js"; @@ -18,69 +20,38 @@ import { MultiWorkerStepRunner } from "./multi-worker-step-runner.js"; import { CoreIpcStepRunner } from "./core-ipc-step-runner.js"; import { WorkflowStatus, StepStatus } from "./types.js"; import { parseDuration } from "./duration.js"; +import { applyEnvPrefixAliases } from "../core/env-aliases.js"; +import { + resolveBranchRuntimeContext, + type BranchRuntimeContext, +} from "./branch-context.js"; + +function isRunningUnderBun(): boolean { + const base = path.basename(process.execPath).toLowerCase(); + return base === "bun" || base === "bun.exe"; +} -// ── Argument parsing ──────────────────────────────────────────── - -const args = process.argv.slice(2); -let yamlPath = ""; -let workspaceDir = ""; -let verbose = false; -let supervised = false; -let keepalive: boolean | undefined; -let keepaliveInterval: string | undefined; -let ipcRequestTimeout: string | undefined; - -for (let i = 0; i < args.length; i++) { - const arg = args[i]!; - if (arg === "--workspace" || arg === "-w") { - i++; - workspaceDir = args[i] ?? ""; - } else if (arg === "--verbose" || arg === "-v") { - verbose = true; - } else if (arg === "--supervised") { - supervised = true; - } else if (arg === "--keepalive") { - keepalive = true; - } else if (arg === "--no-keepalive") { - keepalive = false; - } else if (arg === "--keepalive-interval") { - keepalive = true; - i++; - keepaliveInterval = args[i] ?? ""; - } else if (arg === "--ipc-request-timeout") { - i++; - ipcRequestTimeout = args[i] ?? ""; - } else if (arg === "--help" || arg === "-h") { - console.log(`Usage: bun run src/workflow/run.ts [options] +function resolveCoreEntryPointForSupervised(coreEntryPointOverride: string | undefined): string { + const fromCli = coreEntryPointOverride?.trim(); + if (fromCli) return fromCli; -Options: - --workspace, -w Working directory for steps (default: temp dir) - --verbose, -v Show step output - --supervised Delegate steps via Core IPC (Supervisor -> Core -> Worker) - --keepalive Emit periodic output to avoid no-output watchdogs - --no-keepalive Disable keepalive output - --keepalive-interval Keepalive interval (DurationString; default: 10s) - --ipc-request-timeout IPC request timeout in supervised mode (DurationString; default: 2m) - --help, -h Show help`); - process.exit(0); - } else if (!arg.startsWith("-")) { - yamlPath = arg; - } -} + const fromEnv = (process.env.ROBOPPI_CORE_ENTRYPOINT ?? process.env.AGENTCORE_CORE_ENTRYPOINT)?.trim(); + if (fromEnv) return fromEnv; -if (verbose) { - process.env.AGENTCORE_VERBOSE = "1"; -} + // Compiled binary: spawn this executable as the Core process. + if (!isRunningUnderBun()) { + return process.execPath; + } -// Keepalive flags should propagate to any supervised child processes (Core). -if (keepalive !== undefined) { - process.env.AGENTCORE_KEEPALIVE = keepalive ? "1" : "0"; -} -if (keepaliveInterval !== undefined && keepaliveInterval !== "") { - process.env.AGENTCORE_KEEPALIVE_INTERVAL = keepaliveInterval; + // Dev mode: run Core from source. + return path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "index.ts", + ); } -function resolveIpcRequestTimeoutMs(supervisedMode: boolean): number { +function resolveIpcRequestTimeoutMs(supervisedMode: boolean, ipcRequestTimeout: string | undefined): number { if (!supervisedMode) return 0; const fromCli = ipcRequestTimeout; @@ -113,6 +84,61 @@ function parseDurationOrThrow(value: string, label: string): number { } } +function splitPathList(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(":") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +async function loadAgentCatalogForWorkflow( + workflowYamlPath: string, + verboseMode: boolean, + agentsFiles: string[], +): Promise { + const fromEnv = splitPathList(process.env.AGENTCORE_AGENTS_FILE); + const fromCli = agentsFiles; + + // If any explicit path is provided, only load those (env first, then CLI overrides). + const explicit = [...fromEnv, ...fromCli]; + + const candidates: string[] = []; + if (explicit.length > 0) { + candidates.push(...explicit.map((p) => path.resolve(p))); + } else { + const dir = path.dirname(workflowYamlPath); + candidates.push(path.join(dir, "agents.yaml")); + candidates.push(path.join(dir, "agents.yml")); + } + + let catalog: AgentCatalog | undefined; + for (const p of candidates) { + try { + const content = await readFile(p, "utf-8"); + const parsed = parseAgentCatalog(content); + catalog = { ...(catalog ?? {}), ...parsed }; + if (verboseMode) { + process.stderr.write(`[workflow] loaded agent catalog: ${p} (agents=${Object.keys(parsed).length})\n`); + } + } catch (err: unknown) { + const code = (err as any)?.code; + + // In implicit mode, missing default files are ignored. + if (explicit.length === 0 && code === "ENOENT") { + continue; + } + + if (code === "ENOENT") { + throw new Error(`Agent catalog not found: ${p}`); + } + throw err; + } + } + + return catalog; +} + function isNonInteractive(): boolean { // Bun sets isTTY on stdout/stderr similarly to Node. // Treat either stream being a TTY as interactive. @@ -127,14 +153,14 @@ function parseEnvBool(value: string | undefined): boolean | undefined { return undefined; } -function resolveKeepaliveEnabled(): boolean { +function resolveKeepaliveEnabled(keepalive: boolean | undefined): boolean { if (keepalive !== undefined) return keepalive; const env = parseEnvBool(process.env.AGENTCORE_KEEPALIVE); if (env !== undefined) return env; return isNonInteractive(); } -function resolveKeepaliveIntervalMs(): number { +function resolveKeepaliveIntervalMs(keepaliveInterval: string | undefined): number { const raw = keepaliveInterval ?? process.env.AGENTCORE_KEEPALIVE_INTERVAL ?? @@ -147,12 +173,6 @@ function resolveKeepaliveIntervalMs(): number { } } -if (!yamlPath) { - console.error("Error: workflow YAML path is required"); - console.error("Usage: bun run src/workflow/run.ts "); - process.exit(1); -} - // ── Main ──────────────────────────────────────────────────────── const STATUS_ICON: Record = { @@ -170,152 +190,351 @@ const WF_ICON: Record = { [WorkflowStatus.CANCELLED]: "\x1b[31mCANCELLED\x1b[0m", }; -async function main(): Promise { - // 1. Read and parse YAML - const resolvedPath = path.resolve(yamlPath); - console.log(`\x1b[1mWorkflow:\x1b[0m ${resolvedPath}`); - - const yamlContent = await readFile(resolvedPath, "utf-8"); - const definition = parseWorkflow(yamlContent); - - console.log(`\x1b[1mName:\x1b[0m ${definition.name}`); - console.log(`\x1b[1mSteps:\x1b[0m ${Object.keys(definition.steps).join(", ")}`); - console.log(`\x1b[1mTimeout:\x1b[0m ${definition.timeout}`); - - // 2. Validate DAG - const errors = validateDag(definition); - if (errors.length > 0) { - console.error("\n\x1b[31mDAG validation failed:\x1b[0m"); - for (const e of errors) { - console.error(` - ${e.message}`); +function printBranchContext(context: BranchRuntimeContext): void { + if (!context.enabled) { + console.log("\x1b[1mBranch Lock:\x1b[0m disabled"); + for (const warning of context.warnings) { + process.stderr.write(`[workflow][warn] ${warning}\n`); } - process.exit(1); + return; } - // 3. Setup workspace - const ws = workspaceDir - ? path.resolve(workspaceDir) - : await mkdtemp(path.join(tmpdir(), "agentcore-wf-")); - - const contextDir = path.join(ws, "context"); - console.log(`\x1b[1mWorkspace:\x1b[0m${ws}`); - console.log(""); - - // Default supervised IPC transport. - // - // Some non-interactive runners exhibit broken stdio pipes between parent/child - // processes. Use the socket transport by default in non-interactive mode. - // (Override via AGENTCORE_SUPERVISED_IPC_TRANSPORT=stdio|socket|tcp.) - if (supervised && process.env.AGENTCORE_SUPERVISED_IPC_TRANSPORT === undefined) { - process.env.AGENTCORE_SUPERVISED_IPC_TRANSPORT = isNonInteractive() ? "socket" : "stdio"; + console.log("\x1b[1mBranch Lock:\x1b[0m enabled"); + console.log(` startup_branch: ${context.startupBranch ?? ""}`); + console.log(` startup_head_sha: ${context.startupHeadSha ?? ""}`); + console.log(` startup_toplevel: ${context.startupToplevel ?? ""}`); + console.log(` effective_base_branch: ${context.effectiveBaseBranch ?? ""}`); + console.log(` effective_base_branch_source: ${context.effectiveBaseBranchSource ?? ""}`); + console.log(` effective_base_sha: ${context.effectiveBaseSha ?? ""}`); + console.log(` create_branch: ${context.createBranch}`); + if (context.expectedWorkBranch) { + console.log(` expected_work_branch: ${context.expectedWorkBranch}`); + } + if (context.expectedCurrentBranch) { + console.log(` expected_current_branch: ${context.expectedCurrentBranch}`); + } + if (context.branchTransitionStep) { + console.log(` branch_transition_step: ${context.branchTransitionStep}`); } + console.log(` protected_branches: ${context.protectedBranches.join(",")}`); + console.log(` protected_branches_source: ${context.protectedBranchesSource}`); + console.log(` allow_protected_branch: ${context.allowProtectedBranch}`); - // 4. Execute - const runner = supervised - ? new CoreIpcStepRunner({ - verbose, - coreEntryPoint: path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", - "index.ts", - ), - ipcRequestTimeoutMs: resolveIpcRequestTimeoutMs(true), - }) - : new MultiWorkerStepRunner(verbose); - - let shuttingDown = false; - const shutdown = async (reason: string, exitCode: number) => { - if (shuttingDown) return; - shuttingDown = true; - if (supervised) { - try { - process.stderr.write(`\n[workflow] shutting down Core: ${reason}\n`); - await (runner as CoreIpcStepRunner).shutdown(); - } catch { - // best-effort + for (const warning of context.warnings) { + process.stderr.write(`[workflow][warn] ${warning}\n`); + } +} + +export async function runWorkflowCli(argv: string[]): Promise { + applyEnvPrefixAliases(); + + const args = argv; + let yamlPath = ""; + let workspaceDir = ""; + let verbose = false; + let supervised = true; + let keepalive: boolean | undefined; + let keepaliveInterval: string | undefined; + let ipcRequestTimeout: string | undefined; + let cliBaseBranch: string | undefined; + let cliProtectedBranches: string | undefined; + let cliAllowProtectedBranch = false; + const agentsFiles: string[] = []; + let coreEntryPointOverride: string | undefined; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (arg === "--workspace" || arg === "-w") { + i++; + workspaceDir = args[i] ?? ""; + } else if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if (arg === "--supervised") { + supervised = true; + } else if (arg === "--direct" || arg === "--no-supervised") { + supervised = false; + } else if (arg === "--keepalive") { + keepalive = true; + } else if (arg === "--no-keepalive") { + keepalive = false; + } else if (arg === "--keepalive-interval") { + keepalive = true; + i++; + keepaliveInterval = args[i] ?? ""; + } else if (arg === "--ipc-request-timeout") { + i++; + ipcRequestTimeout = args[i] ?? ""; + } else if (arg === "--base-branch") { + i++; + cliBaseBranch = args[i] ?? ""; + } else if (arg === "--protected-branches") { + i++; + cliProtectedBranches = args[i] ?? ""; + } else if (arg === "--allow-protected-branch") { + cliAllowProtectedBranch = true; + } else if (arg === "--agents") { + i++; + const p = args[i] ?? ""; + if (!p) { + console.error("Error: --agents requires a value"); + process.exit(1); } - } - process.exit(exitCode); - }; + agentsFiles.push(p); + } else if (arg === "--core" || arg === "--core-entrypoint") { + i++; + coreEntryPointOverride = args[i] ?? ""; + } else if (arg === "--help" || arg === "-h") { + console.log(`Usage: + roboppi workflow [options] + (dev) bun run src/workflow/run.ts [options] - if (supervised) { - process.on("SIGINT", () => { - void shutdown("SIGINT", 130); - }); - process.on("SIGTERM", () => { - void shutdown("SIGTERM", 143); - }); +Options: + --workspace, -w Working directory for steps (default: temp dir) + --verbose, -v Show step output + --supervised Supervised mode (default): delegate steps via Core IPC (Supervisor -> Core -> Worker) + --direct Direct mode: spawn worker CLIs directly (no Core IPC) + --no-supervised Alias for --direct + --keepalive Emit periodic output to avoid no-output watchdogs + --no-keepalive Disable keepalive output + --keepalive-interval Keepalive interval (DurationString; default: 10s) + --ipc-request-timeout IPC request timeout in supervised mode (DurationString; default: 2m) + --base-branch Explicit base branch (overrides BASE_BRANCH env) + --protected-branches Protected work branches (default: main,master,release/*) + --allow-protected-branch Allow execution on protected branch (dangerous) + --agents Agent catalog YAML (repeatable; merged with AGENTCORE_AGENTS_FILE; CLI wins on conflicts) + --core Core entrypoint for supervised mode (default: auto) + --help, -h Show help`); + process.exit(0); + } else if (!arg.startsWith("-")) { + yamlPath = arg; + } } - const ctx = new ContextManager(contextDir); - const executor = new WorkflowExecutor( - definition, - ctx, - runner, - ws, - verbose ? { AGENTCORE_VERBOSE: "1" } : undefined, - ); + if (verbose) { + process.env.AGENTCORE_VERBOSE = "1"; + } - const startTime = Date.now(); - const keepaliveEnabled = resolveKeepaliveEnabled(); - const keepaliveIntervalMs = keepaliveEnabled ? resolveKeepaliveIntervalMs() : 0; - let keepaliveTimer: ReturnType | null = null; + // Keepalive flags should propagate to any supervised child processes (Core). + if (keepalive !== undefined) { + process.env.AGENTCORE_KEEPALIVE = keepalive ? "1" : "0"; + } + if (keepaliveInterval !== undefined && keepaliveInterval !== "") { + process.env.AGENTCORE_KEEPALIVE_INTERVAL = keepaliveInterval; + } - if (keepaliveEnabled) { - keepaliveTimer = setInterval(() => { - const elapsedS = Math.floor((Date.now() - startTime) / 1000); - process.stderr.write(`[workflow] keepalive: running (${elapsedS}s elapsed)\n`); - }, keepaliveIntervalMs); + if (!yamlPath) { + console.error("Error: workflow YAML path is required"); + console.error("Usage: roboppi workflow [options]"); + process.exit(1); } - let state; try { - state = await executor.execute(); - } finally { - if (keepaliveTimer) { - clearInterval(keepaliveTimer); + // 1. Read and parse YAML + const resolvedPath = path.resolve(yamlPath); + console.log(`\x1b[1mWorkflow:\x1b[0m ${resolvedPath}`); + + const yamlContent = await readFile(resolvedPath, "utf-8"); + const agentCatalog = await loadAgentCatalogForWorkflow(resolvedPath, verbose, agentsFiles); + const definition = parseWorkflow(yamlContent, { agents: agentCatalog }); + + console.log(`\x1b[1mName:\x1b[0m ${definition.name}`); + console.log(`\x1b[1mSteps:\x1b[0m ${Object.keys(definition.steps).join(", ")}`); + console.log(`\x1b[1mTimeout:\x1b[0m ${definition.timeout}`); + + // 2. Validate DAG + const errors = validateDag(definition); + if (errors.length > 0) { + console.error("\n\x1b[31mDAG validation failed:\x1b[0m"); + for (const e of errors) { + console.error(` - ${e.message}`); + } + process.exit(1); } - if (supervised) { - try { - await (runner as CoreIpcStepRunner).shutdown(); - } catch { - // best-effort + + // 3. Setup workspace + const ws = workspaceDir + ? path.resolve(workspaceDir) + : await mkdtemp(path.join(tmpdir(), "agentcore-wf-")); + + // Context directory can be overridden by workflow YAML (context_dir). + // Resolve relative paths from the workspace root. + const contextDir = definition.context_dir + ? path.resolve(ws, definition.context_dir) + : path.join(ws, "context"); + console.log(`\x1b[1mWorkspace:\x1b[0m${ws}`); + + const branchContext = await resolveBranchRuntimeContext({ + workspaceDir: ws, + cliBaseBranch, + envBaseBranch: process.env.BASE_BRANCH, + cliProtectedBranches, + envProtectedBranches: process.env.AGENTCORE_PROTECTED_BRANCHES, + cliAllowProtectedBranch, + envAllowProtectedBranch: process.env.AGENTCORE_ALLOW_PROTECTED_BRANCH, + createBranch: definition.create_branch ?? false, + expectedWorkBranch: definition.expected_work_branch, + branchTransitionStep: definition.branch_transition_step, + stepIds: Object.keys(definition.steps), + }); + printBranchContext(branchContext); + console.log(""); + + // Default supervised IPC transport. + // + // Some non-interactive runners exhibit broken stdio pipes between parent/child + // processes. Use the socket transport by default in non-interactive mode. + // (Override via AGENTCORE_SUPERVISED_IPC_TRANSPORT=stdio|socket|tcp.) + if (supervised && process.env.AGENTCORE_SUPERVISED_IPC_TRANSPORT === undefined) { + process.env.AGENTCORE_SUPERVISED_IPC_TRANSPORT = isNonInteractive() ? "socket" : "stdio"; + } + + // 4. Execute + const runner = supervised + ? new CoreIpcStepRunner({ + verbose, + coreEntryPoint: resolveCoreEntryPointForSupervised(coreEntryPointOverride), + ipcRequestTimeoutMs: resolveIpcRequestTimeoutMs(true, ipcRequestTimeout), + }) + : new MultiWorkerStepRunner(verbose); + + let shuttingDown = false; + const shutdown = async (reason: string, exitCode: number) => { + if (shuttingDown) return; + shuttingDown = true; + if (supervised) { + try { + process.stderr.write(`\n[workflow] shutting down Core: ${reason}\n`); + await (runner as CoreIpcStepRunner).shutdown(); + } catch { + // best-effort + } } + process.exit(exitCode); + }; + + if (supervised) { + process.on("SIGINT", () => { + void shutdown("SIGINT", 130); + }); + process.on("SIGTERM", () => { + void shutdown("SIGTERM", 143); + }); } - } - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - - // 5. Report results - console.log(""); - console.log("\x1b[1m─── Results ───\x1b[0m"); - console.log(""); - - const stepIds = Object.keys(definition.steps); - const maxLen = Math.max(...stepIds.map((s) => s.length)); - - for (const stepId of stepIds) { - const stepState = state.steps[stepId]!; - const icon = STATUS_ICON[stepState.status] ?? stepState.status; - const pad = " ".repeat(maxLen - stepId.length); - let extra = ""; - if (stepState.iteration > 1) { - extra = ` (${stepState.iteration} iterations)`; + + const ctx = new ContextManager(contextDir); + const workflowEnv: Record = {}; + if (verbose) { + workflowEnv.AGENTCORE_VERBOSE = "1"; } - if (stepState.error) { - extra += ` — ${stepState.error}`; + workflowEnv.AGENTCORE_CREATE_BRANCH = branchContext.createBranch ? "1" : "0"; + workflowEnv.AGENTCORE_PROTECTED_BRANCHES = branchContext.protectedBranches.join(","); + workflowEnv.AGENTCORE_ALLOW_PROTECTED_BRANCH = branchContext.allowProtectedBranch + ? "1" + : "0"; + if (branchContext.effectiveBaseBranch) { + workflowEnv.BASE_BRANCH = branchContext.effectiveBaseBranch; + workflowEnv.AGENTCORE_EFFECTIVE_BASE_BRANCH = branchContext.effectiveBaseBranch; + } + if (branchContext.effectiveBaseBranchSource) { + workflowEnv.AGENTCORE_EFFECTIVE_BASE_BRANCH_SOURCE = + branchContext.effectiveBaseBranchSource; + } + if (branchContext.effectiveBaseSha) { + workflowEnv.AGENTCORE_EFFECTIVE_BASE_SHA = branchContext.effectiveBaseSha; + } + if (branchContext.startupBranch) { + workflowEnv.AGENTCORE_STARTUP_BRANCH = branchContext.startupBranch; + } + if (branchContext.startupHeadSha) { + workflowEnv.AGENTCORE_STARTUP_HEAD_SHA = branchContext.startupHeadSha; + } + if (branchContext.startupToplevel) { + workflowEnv.AGENTCORE_STARTUP_TOPLEVEL = branchContext.startupToplevel; + } + if (branchContext.expectedWorkBranch) { + workflowEnv.AGENTCORE_EXPECTED_WORK_BRANCH = branchContext.expectedWorkBranch; + } + if (branchContext.expectedCurrentBranch) { + workflowEnv.AGENTCORE_EXPECTED_CURRENT_BRANCH = branchContext.expectedCurrentBranch; + } + const executorEnv = + Object.keys(workflowEnv).length > 0 ? workflowEnv : undefined; + const executor = new WorkflowExecutor( + definition, + ctx, + runner, + ws, + executorEnv, + undefined, + branchContext, + ); + + const startTime = Date.now(); + const keepaliveEnabled = resolveKeepaliveEnabled(keepalive); + const keepaliveIntervalMs = keepaliveEnabled ? resolveKeepaliveIntervalMs(keepaliveInterval) : 0; + let keepaliveTimer: ReturnType | null = null; + + if (keepaliveEnabled) { + keepaliveTimer = setInterval(() => { + const elapsedS = Math.floor((Date.now() - startTime) / 1000); + process.stderr.write(`[workflow] keepalive: running (${elapsedS}s elapsed)\n`); + }, keepaliveIntervalMs); + } + + let state; + try { + state = await executor.execute(); + } finally { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + } + if (supervised) { + try { + await (runner as CoreIpcStepRunner).shutdown(); + } catch { + // best-effort + } + } + } + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + // 5. Report results + console.log(""); + console.log("\x1b[1m─── Results ───\x1b[0m"); + console.log(""); + + const stepIds = Object.keys(definition.steps); + const maxLen = Math.max(...stepIds.map((s) => s.length)); + + for (const stepId of stepIds) { + const stepState = state.steps[stepId]!; + const icon = STATUS_ICON[stepState.status] ?? stepState.status; + const pad = " ".repeat(maxLen - stepId.length); + let extra = ""; + if (stepState.iteration > 1) { + extra = ` (${stepState.iteration} iterations)`; + } + if (stepState.error) { + extra += ` — ${stepState.error}`; + } + console.log(` ${icon} ${stepId}${pad}${extra}`); } - console.log(` ${icon} ${stepId}${pad}${extra}`); - } - console.log(""); - const wfIcon = WF_ICON[state.status] ?? state.status; - console.log(`\x1b[1mWorkflow:\x1b[0m ${wfIcon} (${elapsed}s)`); - console.log(`\x1b[1mContext:\x1b[0m ${contextDir}`); + console.log(""); + const wfIcon = WF_ICON[state.status] ?? state.status; + console.log(`\x1b[1mWorkflow:\x1b[0m ${wfIcon} (${elapsed}s)`); + console.log(`\x1b[1mContext:\x1b[0m ${contextDir}`); - process.exit(state.status === WorkflowStatus.SUCCEEDED ? 0 : 1); + process.exit(state.status === WorkflowStatus.SUCCEEDED ? 0 : 1); + } catch (err) { + console.error(`\x1b[31mError:\x1b[0m ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } } -main().catch((err) => { - console.error(`\x1b[31mError:\x1b[0m ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); -}); +if (import.meta.main) { + runWorkflowCli(process.argv.slice(2)).catch((err: unknown) => { + console.error(`\x1b[31mError:\x1b[0m ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + }); +} diff --git a/src/workflow/types.ts b/src/workflow/types.ts index d63a391..a59aaad 100644 --- a/src/workflow/types.ts +++ b/src/workflow/types.ts @@ -7,11 +7,19 @@ export interface WorkflowDefinition { timeout: DurationString; concurrency?: number; context_dir?: string; + /** Whether the workflow is expected to create/switch to a work branch. */ + create_branch?: boolean; + /** Optional step id where the expected work branch transition occurs. */ + branch_transition_step?: string; + /** Optional explicit expected work branch at startup. */ + expected_work_branch?: string; steps: Record; } export interface StepDefinition { description?: string; + /** Optional agent profile id (resolved from an agent catalog). */ + agent?: string; worker: "CODEX_CLI" | "CLAUDE_CODE" | "OPENCODE" | "CUSTOM"; /** Optional model identifier for LLM-backed workers (adapter-specific format). */ model?: string; @@ -84,6 +92,8 @@ export interface ConvergenceDef { } export interface CompletionCheckDef { + /** Optional agent profile id (resolved from an agent catalog). */ + agent?: string; worker: "CODEX_CLI" | "CLAUDE_CODE" | "OPENCODE" | "CUSTOM"; /** Optional model identifier for LLM-backed workers (adapter-specific format). */ model?: string; diff --git a/test/unit/types/worker-task-job-payload.test.ts b/test/unit/types/worker-task-job-payload.test.ts new file mode 100644 index 0000000..373d4dc --- /dev/null +++ b/test/unit/types/worker-task-job-payload.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "bun:test"; +import { isWorkerTaskJobPayload } from "../../../src/types/worker-task-job-payload.js"; + +function validPayload() { + return { + workerTaskId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + workerKind: "CLAUDE_CODE", + workspaceRef: "/workspace", + instructions: "do something", + capabilities: ["READ", "EDIT"], + outputMode: "BATCH", + budget: { + deadlineAt: Date.now() + 60_000, + }, + }; +} + +describe("isWorkerTaskJobPayload", () => { + // ── valid payloads ────────────────────────────────────────────── + + it("returns true for a valid minimal payload", () => { + expect(isWorkerTaskJobPayload(validPayload())).toBe(true); + }); + + it("returns true when all optional fields are present", () => { + const p = { + ...validPayload(), + model: "claude-sonnet-4-6", + env: { FOO: "bar", BAZ: "qux" }, + budget: { + deadlineAt: Date.now() + 60_000, + maxSteps: 100, + maxCommandTimeMs: 30_000, + }, + }; + expect(isWorkerTaskJobPayload(p)).toBe(true); + }); + + it("accepts all valid workerKind values", () => { + for (const kind of ["CODEX_CLI", "CLAUDE_CODE", "OPENCODE", "CUSTOM"]) { + expect(isWorkerTaskJobPayload({ ...validPayload(), workerKind: kind })).toBe(true); + } + }); + + it("accepts all valid capability values", () => { + for (const cap of ["READ", "EDIT", "RUN_TESTS", "RUN_COMMANDS"]) { + expect(isWorkerTaskJobPayload({ ...validPayload(), capabilities: [cap] })).toBe(true); + } + }); + + it("accepts both BATCH and STREAM outputMode", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), outputMode: "BATCH" })).toBe(true); + expect(isWorkerTaskJobPayload({ ...validPayload(), outputMode: "STREAM" })).toBe(true); + }); + + // ── missing required fields ───────────────────────────────────── + + it("returns false for null / undefined / non-object", () => { + expect(isWorkerTaskJobPayload(null)).toBe(false); + expect(isWorkerTaskJobPayload(undefined)).toBe(false); + expect(isWorkerTaskJobPayload("string")).toBe(false); + expect(isWorkerTaskJobPayload(42)).toBe(false); + }); + + for (const field of [ + "workerTaskId", + "workerKind", + "workspaceRef", + "instructions", + "capabilities", + "outputMode", + "budget", + ] as const) { + it(`returns false when ${field} is missing`, () => { + const { [field]: _, ...rest } = validPayload(); + expect(isWorkerTaskJobPayload(rest)).toBe(false); + }); + } + + // ── enum validation ───────────────────────────────────────────── + + it("returns false for unknown workerKind", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), workerKind: "UNKNOWN" })).toBe(false); + }); + + it("returns false for unknown outputMode", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), outputMode: "INVALID" })).toBe(false); + }); + + it("returns false when capabilities contain an unknown value", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), capabilities: ["READ", "FLY"] })).toBe(false); + }); + + it("returns false when capabilities contain a non-string element", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), capabilities: ["READ", 42] })).toBe(false); + }); + + // ── budget validation ─────────────────────────────────────────── + + it("returns false when budget.deadlineAt is missing", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), budget: {} })).toBe(false); + }); + + it("returns false when budget.deadlineAt is NaN", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), budget: { deadlineAt: NaN } })).toBe(false); + }); + + it("returns false when budget.deadlineAt is Infinity", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), budget: { deadlineAt: Infinity } })).toBe(false); + }); + + it("returns false when budget.maxSteps is not a finite number", () => { + const p = { ...validPayload(), budget: { deadlineAt: Date.now() + 1000, maxSteps: NaN } }; + expect(isWorkerTaskJobPayload(p)).toBe(false); + }); + + it("returns false when budget.maxCommandTimeMs is Infinity", () => { + const p = { ...validPayload(), budget: { deadlineAt: Date.now() + 1000, maxCommandTimeMs: Infinity } }; + expect(isWorkerTaskJobPayload(p)).toBe(false); + }); + + // ── env validation ────────────────────────────────────────────── + + it("returns false when env value is not a string", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), env: { OK: "yes", BAD: 123 } })).toBe(false); + }); + + it("returns false when env is an array", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), env: ["a"] })).toBe(false); + }); + + it("returns false when env is null", () => { + expect(isWorkerTaskJobPayload({ ...validPayload(), env: null })).toBe(false); + }); +}); diff --git a/test/unit/workflow/agent-catalog.test.ts b/test/unit/workflow/agent-catalog.test.ts new file mode 100644 index 0000000..74680e7 --- /dev/null +++ b/test/unit/workflow/agent-catalog.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "bun:test"; + +import { parseAgentCatalog, AgentCatalogParseError } from "../../../src/workflow/agent-catalog.js"; + +describe("parseAgentCatalog", () => { + it("parses a minimal catalog", () => { + const yaml = ` +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: "You are a research agent" +`; + + const catalog = parseAgentCatalog(yaml); + expect(catalog.research).toBeDefined(); + expect(catalog.research!.worker).toBe("OPENCODE"); + expect(catalog.research!.model).toBe("openai/gpt-5.2"); + expect(catalog.research!.capabilities).toEqual(["READ"]); + expect(catalog.research!.base_instructions).toBe("You are a research agent"); + }); + + it("rejects wrong version", () => { + const yaml = ` +version: "2" +agents: {} +`; + expect(() => parseAgentCatalog(yaml)).toThrow(AgentCatalogParseError); + }); +}); diff --git a/test/unit/workflow/branch-context.test.ts b/test/unit/workflow/branch-context.test.ts new file mode 100644 index 0000000..3ec6bb6 --- /dev/null +++ b/test/unit/workflow/branch-context.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + isBranchProtected, + resolveBranchRuntimeContext, +} from "../../../src/workflow/branch-context.js"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), "branch-context-test-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("branch-context", () => { + it("resolves startup current branch as effective base when BASE_BRANCH is unset", async () => { + const repoDir = path.join(tempDir, "repo"); + await initRepo(repoDir); + + const context = await resolveBranchRuntimeContext({ + workspaceDir: repoDir, + createBranch: false, + cliAllowProtectedBranch: true, + }); + + expect(context.enabled).toBe(true); + expect(context.startupBranch).toBe("main"); + expect(context.effectiveBaseBranch).toBe("main"); + expect(context.effectiveBaseBranchSource).toBe("current"); + expect(context.expectedWorkBranch).toBe("main"); + }); + + it("uses env BASE_BRANCH when provided and records warning if startup branch differs", async () => { + const repoDir = path.join(tempDir, "repo"); + await initRepo(repoDir); + await gitMust(repoDir, ["checkout", "-b", "feature/demo"]); + + const context = await resolveBranchRuntimeContext({ + workspaceDir: repoDir, + envBaseBranch: "main", + createBranch: true, + }); + + expect(context.enabled).toBe(true); + expect(context.startupBranch).toBe("feature/demo"); + expect(context.effectiveBaseBranch).toBe("main"); + expect(context.effectiveBaseBranchSource).toBe("env"); + expect(context.warnings.length).toBeGreaterThan(0); + }); + + it("prioritizes CLI base branch over env BASE_BRANCH", async () => { + const repoDir = path.join(tempDir, "repo"); + await initRepo(repoDir); + await gitMust(repoDir, ["checkout", "-b", "release/v1"]); + await writeFile(path.join(repoDir, "release.txt"), "v1\n"); + await gitMust(repoDir, ["add", "release.txt"]); + await gitMust(repoDir, ["commit", "-m", "add release branch"]); + + const context = await resolveBranchRuntimeContext({ + workspaceDir: repoDir, + cliBaseBranch: "release/v1", + envBaseBranch: "main", + createBranch: true, + }); + + expect(context.enabled).toBe(true); + expect(context.effectiveBaseBranch).toBe("release/v1"); + expect(context.effectiveBaseBranchSource).toBe("cli"); + }); + + it("fails on detached HEAD when BASE_BRANCH is not explicitly set", async () => { + const repoDir = path.join(tempDir, "repo"); + await initRepo(repoDir); + + const headSha = await gitMust(repoDir, ["rev-parse", "HEAD"]); + await gitMust(repoDir, ["checkout", "--detach", headSha.trim()]); + + await expect( + resolveBranchRuntimeContext({ + workspaceDir: repoDir, + createBranch: true, + }), + ).rejects.toThrow(/detached HEAD/i); + }); + + it("blocks create_branch=false on protected branch without explicit override", async () => { + const repoDir = path.join(tempDir, "repo"); + await initRepo(repoDir); + + await expect( + resolveBranchRuntimeContext({ + workspaceDir: repoDir, + createBranch: false, + }), + ).rejects.toThrow(/protected/i); + }); + + it("allows protected branch when override is enabled", async () => { + const repoDir = path.join(tempDir, "repo"); + await initRepo(repoDir); + + const context = await resolveBranchRuntimeContext({ + workspaceDir: repoDir, + createBranch: false, + envAllowProtectedBranch: "1", + }); + + expect(context.enabled).toBe(true); + expect(context.allowProtectedBranch).toBe(true); + }); + + it("disables branch lock outside git repository", async () => { + const ws = path.join(tempDir, "plain"); + await mkdir(ws, { recursive: true }); + await Bun.write(path.join(ws, "README.md"), "plain workspace\n"); + + const context = await resolveBranchRuntimeContext({ + workspaceDir: ws, + createBranch: false, + }); + + expect(context.enabled).toBe(false); + expect(context.warnings[0]).toContain("not a git repository"); + }); + + it("matches protected branches with exact and glob patterns", () => { + expect(isBranchProtected("main", ["main"])).toBe(true); + expect(isBranchProtected("release/v1", ["release/*"])).toBe(true); + expect(isBranchProtected("feature/x", ["release/*"])).toBe(false); + }); +}); + +async function initRepo(repoDir: string): Promise { + await gitMust(tempDir, ["init", "-b", "main", repoDir]); + await gitMust(repoDir, ["config", "user.name", "Test User"]); + await gitMust(repoDir, ["config", "user.email", "test@example.com"]); + await writeFile(path.join(repoDir, "README.md"), "# test\n"); + await gitMust(repoDir, ["add", "README.md"]); + await gitMust(repoDir, ["commit", "-m", "init"]); +} + +async function gitMust(cwd: string, args: string[]): Promise { + const out = await run(cwd, ["git", ...args]); + if (out.code !== 0) { + throw new Error( + `git ${args.join(" ")} failed: ${out.stderr.trim() || out.stdout.trim() || out.code}`, + ); + } + return out.stdout; +} + +async function run( + cwd: string, + command: string[], +): Promise<{ code: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { code, stdout, stderr }; +} diff --git a/test/unit/workflow/executor.test.ts b/test/unit/workflow/executor.test.ts index b9553e6..ee34667 100644 --- a/test/unit/workflow/executor.test.ts +++ b/test/unit/workflow/executor.test.ts @@ -6,6 +6,7 @@ import { type CheckResult, } from "../../../src/workflow/executor.js"; import { ContextManager } from "../../../src/workflow/context-manager.js"; +import type { BranchRuntimeContext } from "../../../src/workflow/branch-context.js"; import type { WorkflowDefinition, StepDefinition, @@ -138,6 +139,34 @@ async function runCmd( return { code, stdout, stderr }; } +async function runCmdOrThrow(cwd: string, command: string[]): Promise { + const out = await runCmd(cwd, command); + if (out.code !== 0) { + throw new Error( + `${command.join(" ")} failed: ${out.stderr.trim() || out.stdout.trim() || out.code}`, + ); + } + return out.stdout; +} + +async function initGitRepo(cwd: string): Promise<{ + toplevel: string; + branch: string; + headSha: string; +}> { + await runCmdOrThrow(cwd, ["git", "init", "-b", "main"]); + await runCmdOrThrow(cwd, ["git", "config", "user.name", "Test User"]); + await runCmdOrThrow(cwd, ["git", "config", "user.email", "test@example.com"]); + await writeFile(path.join(cwd, "README.md"), "# test\n"); + await runCmdOrThrow(cwd, ["git", "add", "README.md"]); + await runCmdOrThrow(cwd, ["git", "commit", "-m", "init"]); + + const toplevel = (await runCmdOrThrow(cwd, ["git", "rev-parse", "--show-toplevel"])).trim(); + const branch = (await runCmdOrThrow(cwd, ["git", "rev-parse", "--abbrev-ref", "HEAD"])).trim(); + const headSha = (await runCmdOrThrow(cwd, ["git", "rev-parse", "HEAD"])).trim(); + return { toplevel, branch, headSha }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -255,6 +284,105 @@ describe("WorkflowExecutor", () => { }); }); + it("derives completion_check timeout from step timeout / 4 when unspecified", async () => { + await withTempDir(async (dir) => { + let capturedCheckTimeout: string | undefined; + const runner = new MockStepRunner( + async () => ({ status: "SUCCEEDED" }), + async (_stepId, check) => { + capturedCheckTimeout = check.timeout; + return { complete: true, failed: false }; + }, + ); + + const wf = makeWorkflow({ + A: makeStep({ + timeout: "20m", // 20 min = 1_200_000 ms → check default = 300_000 ms + completion_check: { + worker: "CLAUDE_CODE", + instructions: "check", + capabilities: ["READ"], + // no timeout specified + }, + max_iterations: 2, + }), + }); + + const ctx = new ContextManager(path.join(dir, "context")); + const executor = new WorkflowExecutor(wf, ctx, runner, dir); + await executor.execute(); + + // 20 min / 4 = 5 min = 300_000 ms + expect(capturedCheckTimeout).toBe("300000ms"); + }); + }); + + it("preserves explicit completion_check timeout", async () => { + await withTempDir(async (dir) => { + let capturedCheckTimeout: string | undefined; + const runner = new MockStepRunner( + async () => ({ status: "SUCCEEDED" }), + async (_stepId, check) => { + capturedCheckTimeout = check.timeout; + return { complete: true, failed: false }; + }, + ); + + const wf = makeWorkflow({ + A: makeStep({ + timeout: "20m", + completion_check: { + worker: "CLAUDE_CODE", + instructions: "check", + capabilities: ["READ"], + timeout: "2m", // explicit + }, + max_iterations: 2, + }), + }); + + const ctx = new ContextManager(path.join(dir, "context")); + const executor = new WorkflowExecutor(wf, ctx, runner, dir); + await executor.execute(); + + expect(capturedCheckTimeout).toBe("2m"); + }); + }); + + it("falls back to workflow timeout / 4 when step has no timeout", async () => { + await withTempDir(async (dir) => { + let capturedCheckTimeout: string | undefined; + const runner = new MockStepRunner( + async () => ({ status: "SUCCEEDED" }), + async (_stepId, check) => { + capturedCheckTimeout = check.timeout; + return { complete: true, failed: false }; + }, + ); + + const wf = makeWorkflow( + { + A: makeStep({ + // no step timeout → falls back to workflow timeout (10m) + completion_check: { + worker: "CLAUDE_CODE", + instructions: "check", + capabilities: ["READ"], + }, + max_iterations: 2, + }), + }, + { timeout: "10m" }, // 600_000 ms → check = 150_000 ms + ); + + const ctx = new ContextManager(path.join(dir, "context")); + const executor = new WorkflowExecutor(wf, ctx, runner, dir); + await executor.execute(); + + expect(capturedCheckTimeout).toBe("150000ms"); + }); + }); + it("collects outputs written by completion_check", async () => { await withTempDir(async (dir) => { const runner = new MockStepRunner( @@ -975,6 +1103,116 @@ describe("WorkflowExecutor", () => { }); }); + // ----------------------------------------------------------------------- + // Branch lock + // ----------------------------------------------------------------------- + describe("branch lock", () => { + it("fails fast when branch drifts before a step starts", async () => { + await withTempDir(async (dir) => { + const git = await initGitRepo(dir); + await runCmdOrThrow(dir, ["git", "branch", "feature/drift"]); + + const runner = new MockStepRunner(async (stepId) => { + if (stepId === "A") { + await runCmdOrThrow(dir, ["git", "checkout", "feature/drift"]); + } + return { status: "SUCCEEDED" }; + }); + + const wf = makeWorkflow({ + A: makeStep(), + B: makeStep({ depends_on: ["A"] }), + }); + + const branchContext: BranchRuntimeContext = { + enabled: true, + createBranch: false, + expectedWorkBranch: git.branch, + expectedCurrentBranch: git.branch, + startupToplevel: git.toplevel, + startupBranch: git.branch, + startupHeadSha: git.headSha, + effectiveBaseBranch: git.branch, + effectiveBaseBranchSource: "current", + effectiveBaseSha: git.headSha, + protectedBranches: ["main", "master", "release/*"], + protectedBranchesSource: "default", + allowProtectedBranch: true, + warnings: [], + }; + + const ctx = new ContextManager(path.join(dir, "context")); + const executor = new WorkflowExecutor( + wf, + ctx, + runner, + dir, + undefined, + undefined, + branchContext, + ); + const result = await executor.execute(); + + expect(result.status).toBe(WorkflowStatus.FAILED); + expect(result.steps["A"]!.status).toBe(StepStatus.SUCCEEDED); + expect(result.steps["B"]!.status).toBe(StepStatus.FAILED); + expect(result.steps["B"]!.error).toContain("Branch drift detected"); + }); + }); + + it("updates expected branch after transition step when create_branch=true", async () => { + await withTempDir(async (dir) => { + const git = await initGitRepo(dir); + + const runner = new MockStepRunner(async (stepId) => { + if (stepId === "branch") { + await runCmdOrThrow(dir, ["git", "checkout", "-b", "feature/work"]); + } + return { status: "SUCCEEDED" }; + }); + + const wf = makeWorkflow({ + branch: makeStep(), + work: makeStep({ depends_on: ["branch"] }), + }); + + const branchContext: BranchRuntimeContext = { + enabled: true, + createBranch: true, + expectedWorkBranch: git.branch, + expectedCurrentBranch: git.branch, + branchTransitionStep: "branch", + startupToplevel: git.toplevel, + startupBranch: git.branch, + startupHeadSha: git.headSha, + effectiveBaseBranch: git.branch, + effectiveBaseBranchSource: "current", + effectiveBaseSha: git.headSha, + protectedBranches: ["main", "master", "release/*"], + protectedBranchesSource: "default", + allowProtectedBranch: false, + warnings: [], + }; + + const ctx = new ContextManager(path.join(dir, "context")); + const executor = new WorkflowExecutor( + wf, + ctx, + runner, + dir, + undefined, + undefined, + branchContext, + ); + const result = await executor.execute(); + + expect(result.status).toBe(WorkflowStatus.SUCCEEDED); + expect(result.steps["branch"]!.status).toBe(StepStatus.SUCCEEDED); + expect(result.steps["work"]!.status).toBe(StepStatus.SUCCEEDED); + }); + }); + }); + // ----------------------------------------------------------------------- // WorkflowState shape // ----------------------------------------------------------------------- @@ -999,6 +1237,48 @@ describe("WorkflowExecutor", () => { }); }); + // ----------------------------------------------------------------------- + // Step metadata persistence (_meta.json + _resolved.json) + // ----------------------------------------------------------------------- + describe("step metadata files", () => { + it("writes both _meta.json and _resolved.json for each step", async () => { + await withTempDir(async (dir) => { + const runner = new MockStepRunner(); + const wf = makeWorkflow({ + A: makeStep({ timeout: "5m", max_steps: 10 }), + }); + const ctxDir = path.join(dir, "context"); + const ctx = new ContextManager(ctxDir); + const executor = new WorkflowExecutor(wf, ctx, runner, dir); + await executor.execute(); + + // _meta.json should exist and contain step execution metadata + const meta = JSON.parse( + await readFile(path.join(ctxDir, "A", "_meta.json"), "utf-8"), + ) as Record; + expect(meta["stepId"]).toBe("A"); + expect(meta["status"]).toBe("SUCCEEDED"); + expect(typeof meta["startedAt"]).toBe("number"); + expect(meta["workerKind"]).toBe("CODEX_CLI"); + expect(meta).not.toHaveProperty("resolved"); + + // _resolved.json should exist and include resolved parameters + const resolved = JSON.parse( + await readFile(path.join(ctxDir, "A", "_resolved.json"), "utf-8"), + ) as Record; + expect(resolved["stepId"]).toBe("A"); + expect(resolved["status"]).toBe("SUCCEEDED"); + + const r = resolved["resolved"] as Record; + expect(r).toBeDefined(); + expect(r["timeoutMs"]).toBe(300_000); + expect(r["workerKind"]).toBe("CODEX_CLI"); + expect(r["workspaceRef"]).toBe(dir); + expect(r["maxSteps"]).toBe(10); + }); + }); + }); + // ----------------------------------------------------------------------- // Workflow metadata persistence // ----------------------------------------------------------------------- diff --git a/test/unit/workflow/parser.test.ts b/test/unit/workflow/parser.test.ts index b2571d1..8cdea5f 100644 --- a/test/unit/workflow/parser.test.ts +++ b/test/unit/workflow/parser.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "bun:test"; import { parseWorkflow, WorkflowParseError } from "../../../src/workflow/parser.js"; +import { parseAgentCatalog } from "../../../src/workflow/agent-catalog.js"; const MINIMAL_WORKFLOW = ` name: test-workflow @@ -33,6 +34,9 @@ description: "A full workflow" timeout: "1h" concurrency: 3 context_dir: "./my-context" +create_branch: true +branch_transition_step: "s1" +expected_work_branch: "feature/demo" steps: s1: worker: CLAUDE_CODE @@ -43,6 +47,9 @@ steps: expect(wf.description).toBe("A full workflow"); expect(wf.concurrency).toBe(3); expect(wf.context_dir).toBe("./my-context"); + expect(wf.create_branch).toBe(true); + expect(wf.branch_transition_step).toBe("s1"); + expect(wf.expected_work_branch).toBe("feature/demo"); }); it("parses step with all optional fields", () => { @@ -126,7 +133,7 @@ steps: instructions: "Check completeness" capabilities: [READ] timeout: "2m" - decision_file: ".agentcore-loop/review.verdict" + decision_file: ".roboppi-loop/review.verdict" max_iterations: 5 on_iterations_exhausted: continue `; @@ -137,7 +144,7 @@ steps: expect(step.completion_check!.instructions).toBe("Check completeness"); expect(step.completion_check!.capabilities).toEqual(["READ"]); expect(step.completion_check!.timeout).toBe("2m"); - expect(step.completion_check!.decision_file).toBe(".agentcore-loop/review.verdict"); + expect(step.completion_check!.decision_file).toBe(".roboppi-loop/review.verdict"); expect(step.max_iterations).toBe(5); expect(step.on_iterations_exhausted).toBe("continue"); }); @@ -174,6 +181,65 @@ steps: const wf = parseWorkflow(yaml); expect(wf.steps["s"]!.capabilities).toEqual(["READ", "EDIT", "RUN_TESTS", "RUN_COMMANDS"]); }); + + it("resolves step.agent from an agent catalog", () => { + const agents = parseAgentCatalog(` +version: "1" +agents: + research: + worker: OPENCODE + model: openai/gpt-5.2 + capabilities: [READ] + base_instructions: | + You are a research agent. +`); + + const yaml = ` +name: w +version: "1" +timeout: "5m" +steps: + s: + agent: research + instructions: "Find relevant docs" +`; + + const wf = parseWorkflow(yaml, { agents }); + expect(wf.steps["s"]!.worker).toBe("OPENCODE"); + expect(wf.steps["s"]!.model).toBe("openai/gpt-5.2"); + expect(wf.steps["s"]!.capabilities).toEqual(["READ"]); + expect(wf.steps["s"]!.instructions).toBe("You are a research agent.\n\nFind relevant docs"); + }); + + it("resolves completion_check.agent from an agent catalog", () => { + const agents = parseAgentCatalog(` +version: "1" +agents: + checker: + worker: CUSTOM + capabilities: [READ] +`); + + const yaml = ` +name: w +version: "1" +timeout: "5m" +steps: + s: + worker: CUSTOM + instructions: "echo ok" + capabilities: [READ] + completion_check: + agent: checker + instructions: "exit 0" + decision_file: ".roboppi-loop/decision.txt" + max_iterations: 2 +`; + + const wf = parseWorkflow(yaml, { agents }); + expect(wf.steps["s"]!.completion_check!.worker).toBe("CUSTOM"); + expect(wf.steps["s"]!.completion_check!.capabilities).toEqual(["READ"]); + }); }); describe("validation errors", () => { @@ -234,6 +300,21 @@ timeout: "5m" expect(() => parseWorkflow(yaml)).toThrow(/"steps" must be an object/); }); + it("rejects unknown branch_transition_step", () => { + const yaml = ` +name: w +version: "1" +timeout: "5m" +branch_transition_step: "missing" +steps: + s: + worker: CODEX_CLI + instructions: "x" + capabilities: [READ] +`; + expect(() => parseWorkflow(yaml)).toThrow(/branch_transition_step/); + }); + it("rejects empty steps", () => { const yaml = ` name: w @@ -389,5 +470,18 @@ steps: `; expect(() => parseWorkflow(yaml)).toThrow(/must be one of/); }); + + it("rejects step.agent when no agent catalog is provided", () => { + const yaml = ` +name: w +version: "1" +timeout: "5m" +steps: + s: + agent: research + instructions: "x" +`; + expect(() => parseWorkflow(yaml)).toThrow(/no agent catalog/); + }); }); }); diff --git a/test/unit/workflow/resolve-worker-task.test.ts b/test/unit/workflow/resolve-worker-task.test.ts new file mode 100644 index 0000000..9ef64d5 --- /dev/null +++ b/test/unit/workflow/resolve-worker-task.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect } from "bun:test"; +import path from "node:path"; +import { + resolveTaskLike, + buildWorkerTask, + DEFAULT_STEP_TIMEOUT_MS, + type ResolvedWorkerTaskDef, +} from "../../../src/workflow/resolve-worker-task.js"; +import { + WorkerKind, + WorkerCapability, + OutputMode, +} from "../../../src/types/index.js"; + +describe("resolveTaskLike", () => { + const baseWorkspace = "/home/user/project"; + + it("resolves workspace to absolute path", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + workspace: "src", + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + ); + expect(resolved.workspaceRef).toBe(path.resolve(baseWorkspace, "src")); + }); + + it("uses workspaceDir when workspace is not specified", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + ); + expect(resolved.workspaceRef).toBe(baseWorkspace); + }); + + it("parses timeout DurationString to ms", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + timeout: "5m", + }, + baseWorkspace, + ); + expect(resolved.timeoutMs).toBe(300_000); + }); + + it("parses compound timeout (1h30m)", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + timeout: "1h30m", + }, + baseWorkspace, + ); + expect(resolved.timeoutMs).toBe(5_400_000); + }); + + it("uses default timeout when not specified", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + ); + expect(resolved.timeoutMs).toBe(DEFAULT_STEP_TIMEOUT_MS); + expect(resolved.timeoutMs).toBe(24 * 60 * 60 * 1000); + }); + + it("parses max_command_time DurationString", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + max_command_time: "30s", + }, + baseWorkspace, + ); + expect(resolved.maxCommandTimeMs).toBe(30_000); + }); + + it("omits maxCommandTimeMs when max_command_time is not specified", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + ); + expect(resolved.maxCommandTimeMs).toBeUndefined(); + }); + + it("maps capabilities strings to WorkerCapability enums", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ", "EDIT", "RUN_TESTS", "RUN_COMMANDS"], + }, + baseWorkspace, + ); + expect(resolved.capabilities).toEqual([ + WorkerCapability.READ, + WorkerCapability.EDIT, + WorkerCapability.RUN_TESTS, + WorkerCapability.RUN_COMMANDS, + ]); + }); + + it.each([ + ["CODEX_CLI", WorkerKind.CODEX_CLI], + ["CLAUDE_CODE", WorkerKind.CLAUDE_CODE], + ["OPENCODE", WorkerKind.OPENCODE], + ["CUSTOM", WorkerKind.CUSTOM], + ] as const)("maps worker %s to WorkerKind enum", (worker, expected) => { + const resolved = resolveTaskLike( + { + worker, + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + ); + expect(resolved.workerKind).toBe(expected); + }); + + it("passes through model when specified", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + model: "gpt-4", + }, + baseWorkspace, + ); + expect(resolved.model).toBe("gpt-4"); + }); + + it("omits model when not specified", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + ); + expect(resolved.model).toBeUndefined(); + }); + + it("passes through max_steps", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + max_steps: 100, + }, + baseWorkspace, + ); + expect(resolved.maxSteps).toBe(100); + }); + + it("passes through env", () => { + const env = { FOO: "bar", BAZ: "qux" }; + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + env, + ); + expect(resolved.env).toEqual(env); + }); + + it("omits env when not provided", () => { + const resolved = resolveTaskLike( + { + worker: "CODEX_CLI", + instructions: "do work", + capabilities: ["READ"], + }, + baseWorkspace, + ); + expect(resolved.env).toBeUndefined(); + }); +}); + +describe("buildWorkerTask", () => { + const baseResolved: ResolvedWorkerTaskDef = { + workerKind: WorkerKind.CODEX_CLI, + workspaceRef: "/home/user/project", + instructions: "do work", + capabilities: [WorkerCapability.READ, WorkerCapability.EDIT], + timeoutMs: 300_000, + }; + + it("creates a WorkerTask with a unique id", () => { + const ac = new AbortController(); + const task = buildWorkerTask(baseResolved, ac.signal); + expect(task.workerTaskId).toBeDefined(); + expect(typeof task.workerTaskId).toBe("string"); + expect(task.workerTaskId.length).toBeGreaterThan(0); + }); + + it("sets deadlineAt based on timeoutMs", () => { + const ac = new AbortController(); + const before = Date.now(); + const task = buildWorkerTask(baseResolved, ac.signal); + const after = Date.now(); + + expect(task.budget.deadlineAt).toBeGreaterThanOrEqual(before + 300_000); + expect(task.budget.deadlineAt).toBeLessThanOrEqual(after + 300_000); + }); + + it("sets outputMode to BATCH", () => { + const ac = new AbortController(); + const task = buildWorkerTask(baseResolved, ac.signal); + expect(task.outputMode).toBe(OutputMode.BATCH); + }); + + it("includes model when present", () => { + const ac = new AbortController(); + const task = buildWorkerTask({ ...baseResolved, model: "gpt-4" }, ac.signal); + expect(task.model).toBe("gpt-4"); + }); + + it("includes maxSteps in budget when present", () => { + const ac = new AbortController(); + const task = buildWorkerTask({ ...baseResolved, maxSteps: 50 }, ac.signal); + expect(task.budget.maxSteps).toBe(50); + }); + + it("includes maxCommandTimeMs in budget when present", () => { + const ac = new AbortController(); + const task = buildWorkerTask( + { ...baseResolved, maxCommandTimeMs: 60_000 }, + ac.signal, + ); + expect(task.budget.maxCommandTimeMs).toBe(60_000); + }); + + it("passes env to WorkerTask", () => { + const ac = new AbortController(); + const env = { KEY: "val" }; + const task = buildWorkerTask({ ...baseResolved, env }, ac.signal); + expect(task.env).toEqual(env); + }); + + it("passes abortSignal to WorkerTask", () => { + const ac = new AbortController(); + const task = buildWorkerTask(baseResolved, ac.signal); + expect(task.abortSignal).toBe(ac.signal); + }); + + it("generates unique ids across calls", () => { + const ac = new AbortController(); + const t1 = buildWorkerTask(baseResolved, ac.signal); + const t2 = buildWorkerTask(baseResolved, ac.signal); + expect(t1.workerTaskId).not.toBe(t2.workerTaskId); + }); +}); diff --git a/tests/at/cli-subcommands.test.ts b/tests/at/cli-subcommands.test.ts new file mode 100644 index 0000000..2c52b77 --- /dev/null +++ b/tests/at/cli-subcommands.test.ts @@ -0,0 +1,483 @@ +/** + * CLI E2E tests + * + * Based on tests/cli-test-plan.md + * + * These tests intentionally spawn the CLI as a real process. + */ +import { describe, it, expect } from "bun:test"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +type CliExit = { + code: number | null; + signal: NodeJS.Signals | null; +}; + +type CliResult = CliExit & { + stdout: string; + stderr: string; +}; + +const REPO_ROOT = process.cwd(); + +function stripAnsi(input: string): string { + // Good-enough ANSI stripper for CLI status output. + return input.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function createCleanEnv(extra?: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + + // Keep tests deterministic and avoid accidental dependency on dev shells. + for (const key of [ + "AGENTCORE_AGENTS_FILE", + "ROBOPPI_AGENTS_FILE", + "AGENTCORE_CORE_ENTRYPOINT", + "ROBOPPI_CORE_ENTRYPOINT", + "AGENTCORE_SUPERVISED_IPC_TRANSPORT", + "ROBOPPI_SUPERVISED_IPC_TRANSPORT", + "AGENTCORE_KEEPALIVE", + "AGENTCORE_KEEPALIVE_INTERVAL", + "AGENTCORE_IPC_TRACE", + "ROBOPPI_IPC_TRACE", + ]) { + delete env[key]; + } + + env.PATH = env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; + env.HOME = env.HOME ?? ""; + + if (extra) { + for (const [k, v] of Object.entries(extra)) { + if (v === undefined) delete env[k]; + else env[k] = v; + } + } + return env; +} + +function spawnCli(args: string[], options?: { env?: NodeJS.ProcessEnv; cwd?: string }): { + child: ChildProcessWithoutNullStreams; + waitForExit: () => Promise; + getStdout: () => string; + getStderr: () => string; +} { + const env = options?.env ?? createCleanEnv(); + const cwd = options?.cwd ?? REPO_ROOT; + + const child = spawn(process.execPath, ["run", "src/cli.ts", ...args], { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + + const exitPromise: Promise = new Promise((resolve) => { + child.once("close", (code, signal) => resolve({ code, signal })); + }); + const waitForExit = (): Promise => exitPromise; + + return { + child, + waitForExit, + getStdout: () => stdout, + getStderr: () => stderr, + }; +} + +async function killProcess( + child: ChildProcessWithoutNullStreams, + waitForExit: () => Promise, + timeoutMs = 2000, +): Promise { + if (child.killed) { + return await waitForExit(); + } + + try { + child.kill("SIGTERM"); + } catch { + // best-effort + } + + let timer: ReturnType | null = null; + try { + return await Promise.race([ + waitForExit(), + new Promise((resolve) => { + timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // best-effort + } + waitForExit().then(resolve); + }, timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function runCli(args: string[], options?: { stdin?: string; timeoutMs?: number }): Promise { + const { child, waitForExit, getStdout, getStderr } = spawnCli(args); + const timeoutMs = options?.timeoutMs ?? 20_000; + + let timer: ReturnType | null = null; + try { + if (options?.stdin !== undefined) { + child.stdin.write(options.stdin); + child.stdin.end(); + } + + const exit = await Promise.race([ + waitForExit(), + new Promise((resolve) => { + timer = setTimeout(() => { + killProcess(child, waitForExit, 1000).then(resolve); + }, timeoutMs); + }), + ]); + + return { ...exit, stdout: getStdout(), stderr: getStderr() }; + } finally { + if (timer) clearTimeout(timer); + } +} + +function createLineReader(stream: NodeJS.ReadableStream): { + nextLine: (timeoutMs?: number) => Promise; + close: () => void; +} { + let buffer = ""; + const queue: string[] = []; + const waiters: Array<(line: string) => void> = []; + let ended = false; + + const onData = (chunk: Buffer | string) => { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + while (true) { + const idx = buffer.indexOf("\n"); + if (idx === -1) break; + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (waiters.length > 0) { + const w = waiters.shift()!; + w(line); + } else { + queue.push(line); + } + } + }; + + const onEnd = () => { + ended = true; + while (waiters.length > 0) { + const w = waiters.shift()!; + w(""); + } + }; + + stream.on("data", onData); + stream.on("end", onEnd); + stream.on("close", onEnd); + + const nextLine = (timeoutMs = 5000): Promise => { + if (queue.length > 0) return Promise.resolve(queue.shift()!); + if (ended) return Promise.resolve(""); + + return new Promise((resolve, reject) => { + let timer: ReturnType | null = null; + const done = (line: string) => { + if (timer) clearTimeout(timer); + resolve(line); + }; + waiters.push(done); + timer = setTimeout(() => { + const idx = waiters.indexOf(done); + if (idx !== -1) waiters.splice(idx, 1); + reject(new Error(`Timed out waiting for next line after ${timeoutMs}ms`)); + }, timeoutMs); + }); + }; + + const close = () => { + stream.off("data", onData); + stream.off("end", onEnd); + stream.off("close", onEnd); + }; + + return { nextLine, close }; +} + +async function waitForMatch( + getText: () => string, + onChunk: (handler: () => void) => () => void, + pattern: RegExp, + timeoutMs = 5000, +): Promise { + if (pattern.test(getText())) return; + + let timer: ReturnType | null = null; + let done = false; + return await new Promise((resolve, reject) => { + const unsubscribe = onChunk(() => { + if (done) return; + if (pattern.test(getText())) { + done = true; + if (timer) clearTimeout(timer); + unsubscribe(); + resolve(); + } + }); + + timer = setTimeout(() => { + if (done) return; + done = true; + unsubscribe(); + reject(new Error(`Timed out waiting for pattern: ${String(pattern)}`)); + }, timeoutMs); + }); +} + +describe("CLI E2E (bun run src/cli.ts ...)", () => { + it( + "TC-CLI-01: root help shows subcommands", + async () => { + const res = await runCli(["--help"], { timeoutMs: 10_000 }); + expect(res.code).toBe(0); + expect(res.stdout).toContain("roboppi run"); + expect(res.stdout).toContain("roboppi workflow"); + expect(res.stdout).toContain("roboppi daemon"); + expect(res.stdout).toContain("roboppi agent"); + }, + 15_000, + ); + + it( + "TC-CLI-02: workflow help", + async () => { + const res = await runCli(["workflow", "--help"], { timeoutMs: 10_000 }); + expect(res.code).toBe(0); + expect(res.stdout).toContain("roboppi workflow "); + }, + 15_000, + ); + + it( + "TC-CLI-03: daemon help", + async () => { + const res = await runCli(["daemon", "--help"], { timeoutMs: 10_000 }); + expect(res.code).toBe(0); + expect(res.stdout).toContain("roboppi daemon "); + }, + 15_000, + ); + + it( + "TC-CLI-04: agent is alias of run (missing required args)", + async () => { + const runRes = await runCli(["run"], { timeoutMs: 20_000 }); + const agentRes = await runCli(["agent"], { timeoutMs: 20_000 }); + + expect(runRes.code).not.toBe(0); + expect(agentRes.code).not.toBe(0); + + expect(runRes.code).toBe(agentRes.code); + expect(runRes.stderr).toContain("--worker is required"); + expect(agentRes.stderr).toContain("--worker is required"); + }, + 60_000, + ); + + it( + "TC-WF-01: workflow (supervised default) runs hello-world", + async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cli-e2e-wf-")); + try { + const ws = path.join(dir, "ws"); + await mkdir(ws, { recursive: true }); + + const res = await runCli( + [ + "workflow", + "examples/hello-world.yaml", + "--verbose", + "--workspace", + ws, + ], + { timeoutMs: 60_000 }, + ); + + expect(res.code).toBe(0); + expect(stripAnsi(res.stdout)).toMatch(/PASS\s+greet/); + + const helloPath = path.join(ws, "hello.txt"); + const content = await readFile(helloPath, "utf-8"); + expect(content).toContain("Hello from AgentCore Workflow!"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 90_000, + ); + + it( + "TC-WF-02: workflow (direct) runs hello-world", + async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cli-e2e-wf-direct-")); + try { + const ws = path.join(dir, "ws"); + await mkdir(ws, { recursive: true }); + + const res = await runCli( + [ + "workflow", + "examples/hello-world.yaml", + "--direct", + "--verbose", + "--workspace", + ws, + ], + { timeoutMs: 60_000 }, + ); + + expect(res.code).toBe(0); + expect(stripAnsi(res.stdout)).toMatch(/PASS\s+greet/); + + const helloPath = path.join(ws, "hello.txt"); + const content = await readFile(helloPath, "utf-8"); + expect(content).toContain("Hello from AgentCore Workflow!"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 90_000, + ); + + it( + "TC-DMN-01: daemon starts and can be stopped quickly", + async () => { + const dir = await mkdtemp(path.join(tmpdir(), "cli-e2e-daemon-")); + try { + const ws = path.join(dir, "ws"); + await mkdir(ws, { recursive: true }); + + const daemonYamlPath = path.join(dir, "daemon.yaml"); + await writeFile( + daemonYamlPath, + [ + "name: e2e-daemon", + "version: \"1\"", + "workspace: \"" + ws.replace(/\\/g, "\\\\") + "\"", + "", + "events:", + " tick:", + " type: interval", + " every: \"30s\"", + "", + "triggers:", + " noop:", + " on: tick", + " workflow: \"./does-not-run.yaml\"", + " on_workflow_failure: ignore", + "", + ].join("\n"), + "utf-8", + ); + + const env = createCleanEnv(); + const { child, waitForExit, getStderr } = spawnCli( + ["daemon", daemonYamlPath, "--direct", "--verbose"], + { env }, + ); + + const unsubscribers: Array<() => void> = []; + const onStderrChunk = (handler: () => void) => { + const fn = () => handler(); + child.stderr.on("data", fn); + const unsub = () => child.stderr.off("data", fn); + unsubscribers.push(unsub); + return unsub; + }; + + try { + await waitForMatch( + getStderr, + onStderrChunk, + /Event loop started, waiting for events\.\.\./, + 10_000, + ); + + const exit = await killProcess(child, waitForExit, 5000); + expect(exit.code === 0 || exit.signal === "SIGTERM").toBe(true); + } finally { + for (const u of unsubscribers) u(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 30_000, + ); + + it( + "TC-SRV-01: Core IPC server returns JSONL ack on stdout", + async () => { + const env = createCleanEnv(); + const { child, waitForExit, getStdout, getStderr } = spawnCli([], { env }); + + const stdoutReader = createLineReader(child.stdout); + try { + const job = { + jobId: "e2e-job-1", + type: "WORKER_TASK", + priority: { value: 1, class: "INTERACTIVE" }, + payload: {}, + limits: { timeoutMs: 1000, maxAttempts: 1 }, + context: { traceId: "t", correlationId: "c" }, + }; + const requestId = "e2e-req-1"; + const msg = { type: "submit_job", requestId, job }; + + child.stdin.write(JSON.stringify(msg) + "\n"); + + const line = await stdoutReader.nextLine(10_000); + const trimmed = line.trim(); + expect(trimmed.startsWith("{")).toBe(true); + expect(trimmed.endsWith("}")).toBe(true); + const parsed = JSON.parse(trimmed) as Record; + expect(parsed["type"]).toBe("ack"); + expect(parsed["requestId"]).toBe(requestId); + expect(parsed["jobId"]).toBe("e2e-job-1"); + + const exit = await killProcess(child, waitForExit, 5000); + expect(exit.code === 0 || exit.signal === "SIGTERM").toBe(true); + } catch (err) { + // Surface child stderr for easier debugging. + const detail = `stdout=\n${getStdout()}\n\nstderr=\n${getStderr()}`; + throw new Error(`${err instanceof Error ? err.message : String(err)}\n\n${detail}`); + } finally { + stdoutReader.close(); + await killProcess(child, waitForExit, 1000).catch(() => {}); + } + }, + 30_000, + ); +}); diff --git a/tests/branch/run-branch-verification.sh b/tests/branch/run-branch-verification.sh new file mode 100755 index 0000000..5c9ec58 --- /dev/null +++ b/tests/branch/run-branch-verification.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd -- "${SCRIPT_DIR}/../.." && pwd) + +if ! command -v bun >/dev/null 2>&1; then + echo "Error: bun is required" >&2 + exit 1 +fi +if ! command -v git >/dev/null 2>&1; then + echo "Error: git is required" >&2 + exit 1 +fi + +HAS_RG=0 +if command -v rg >/dev/null 2>&1; then + HAS_RG=1 +fi + +VERIFY_DIR=${1:-"$(mktemp -d /tmp/agentcore-branch-verify-XXXXXX)"} +mkdir -p "${VERIFY_DIR}" +LOG_DIR="${VERIFY_DIR}/logs" +mkdir -p "${LOG_DIR}" + +cat > "${VERIFY_DIR}/wf-default.yaml" <<'YAML' +name: verify-default-current +version: "1" +timeout: "2m" +create_branch: false +steps: + one: + worker: CUSTOM + instructions: | + git rev-parse --abbrev-ref HEAD > .branch-before.txt + echo ok > .ok.txt + capabilities: [READ, EDIT, RUN_COMMANDS] + outputs: + - name: ok + path: .ok.txt +YAML + +cat > "${VERIFY_DIR}/wf-drift.yaml" <<'YAML' +name: verify-drift-detection +version: "1" +timeout: "2m" +create_branch: false +steps: + mutate_branch: + worker: CUSTOM + instructions: | + git checkout main + echo switched > .switched.txt + capabilities: [READ, EDIT, RUN_COMMANDS] + should_not_run: + worker: CUSTOM + depends_on: [mutate_branch] + instructions: | + echo should-not-run > .unexpected.txt + capabilities: [READ, EDIT, RUN_COMMANDS] +YAML + +cat > "${VERIFY_DIR}/wf-transition.yaml" <<'YAML' +name: verify-transition +version: "1" +timeout: "2m" +create_branch: true +branch_transition_step: branch +steps: + branch: + worker: CUSTOM + instructions: | + git checkout -b feature/transition-pass + echo branched > .branched.txt + capabilities: [READ, EDIT, RUN_COMMANDS] + work: + worker: CUSTOM + depends_on: [branch] + instructions: | + git rev-parse --abbrev-ref HEAD > .branch-after.txt + echo work-ok > .work.txt + capabilities: [READ, EDIT, RUN_COMMANDS] +YAML + +cat > "${VERIFY_DIR}/wf-protected.yaml" <<'YAML' +name: verify-protected-guard +version: "1" +timeout: "2m" +create_branch: false +steps: + one: + worker: CUSTOM + instructions: | + echo protected-test > .protected.txt + capabilities: [READ, EDIT, RUN_COMMANDS] +YAML + +init_repo() { + if [ -d "${VERIFY_DIR}/.git" ]; then + return + fi + git -C "${VERIFY_DIR}" init -b main >/dev/null + git -C "${VERIFY_DIR}" config user.name "Verify Bot" + git -C "${VERIFY_DIR}" config user.email "verify@example.com" + echo "hello" > "${VERIFY_DIR}/README.md" + git -C "${VERIFY_DIR}" add README.md + git -C "${VERIFY_DIR}" commit -m "init" >/dev/null +} + +run_wf() { + local wf="$1" + local log="$2" + shift 2 + set +e + (cd "${REPO_ROOT}" && bun run src/workflow/run.ts "${VERIFY_DIR}/${wf}" --workspace "${VERIFY_DIR}" --verbose "$@" > "${log}" 2>&1) + local code=$? + set -e + return "${code}" +} + +assert_contains() { + local file="$1" + local pattern="$2" + if [ "${HAS_RG}" = "1" ] && rg -q -- "$pattern" "$file"; then + return + fi + if [ "${HAS_RG}" = "0" ] && grep -Eq -- "$pattern" "$file"; then + return + fi + echo "ASSERTION FAILED: pattern not found" >&2 + echo " file: $file" >&2 + echo " pattern: $pattern" >&2 + exit 1 +} + +init_repo + +git -C "${VERIFY_DIR}" checkout -B feature/verify >/dev/null + +# TC-01 +if run_wf "wf-default.yaml" "${LOG_DIR}/out-default.log"; then + : +else + echo "TC-01 failed: expected success" >&2 + exit 1 +fi +assert_contains "${LOG_DIR}/out-default.log" "effective_base_branch: feature/verify" +assert_contains "${LOG_DIR}/out-default.log" "effective_base_branch_source: current" +if [ "$(cat "${VERIFY_DIR}/.branch-before.txt")" != "feature/verify" ]; then + echo "TC-01 failed: .branch-before.txt mismatch" >&2 + exit 1 +fi + +# TC-02 +if run_wf "wf-drift.yaml" "${LOG_DIR}/out-drift.log"; then + echo "TC-02 failed: expected failure" >&2 + exit 1 +fi +assert_contains "${LOG_DIR}/out-drift.log" "Branch drift detected" + +# TC-03 +git -C "${VERIFY_DIR}" checkout feature/verify >/dev/null +if run_wf "wf-transition.yaml" "${LOG_DIR}/out-transition.log"; then + : +else + echo "TC-03 failed: expected success" >&2 + exit 1 +fi +assert_contains "${LOG_DIR}/out-transition.log" "branch_transition_step: branch" +if [ "$(cat "${VERIFY_DIR}/.branch-after.txt")" != "feature/transition-pass" ]; then + echo "TC-03 failed: .branch-after.txt mismatch" >&2 + exit 1 +fi + +# TC-04 / TC-05 +git -C "${VERIFY_DIR}" checkout main >/dev/null +if run_wf "wf-protected.yaml" "${LOG_DIR}/out-protected-block.log"; then + echo "TC-04 failed: expected failure" >&2 + exit 1 +fi +assert_contains "${LOG_DIR}/out-protected-block.log" "expected_work_branch \"main\" is protected" + +if run_wf "wf-protected.yaml" "${LOG_DIR}/out-protected-allow.log" --allow-protected-branch; then + : +else + echo "TC-05 failed: expected success" >&2 + exit 1 +fi +assert_contains "${LOG_DIR}/out-protected-allow.log" "allow_protected_branch: true" + +echo "All branch verification test cases passed" +echo "VERIFY_DIR=${VERIFY_DIR}" +echo "LOG_DIR=${LOG_DIR}" diff --git a/tests/branch/test-results.md b/tests/branch/test-results.md new file mode 100644 index 0000000..3fbb216 --- /dev/null +++ b/tests/branch/test-results.md @@ -0,0 +1,51 @@ +# Branch Lock / Base Branch テスト結果 + +## 1. 実施情報 +- 実施日時 (UTC): 2026-02-15T11:07:11Z +- 実施日時 (Local): 2026-02-15T20:07:11+0900 +- 実施環境: + - `bun 1.3.8` + - `git version 2.53.0` +- 実行コマンド: + - `bash tests/branch/run-branch-verification.sh` +- 検証ディレクトリ: + - `/tmp/agentcore-branch-verify-rT3LQh` +- ログディレクトリ: + - `/tmp/agentcore-branch-verify-rT3LQh/logs` + +## 2. 結果サマリ + +| Test Case | 内容 | 期待 | 結果 | 判定 | +|---|---|---|---|---| +| TC-01 | `BASE_BRANCH` 未指定で current branch 採用 | `effective_base_branch=feature/verify` | `out-default.log` で確認 | PASS | +| TC-02 | branch drift fail-fast | `should_not_run` 実行前に停止 | `Branch drift detected` で失敗終了 | PASS | +| TC-03 | `create_branch=true` 遷移許容 | 遷移後ブランチで後続 step 実行 | `branch_transition_step: branch` + Workflow SUCCEEDED | PASS | +| TC-04 | protected branch ブロック | `main` で fail-fast | `expected_work_branch "main" is protected` | PASS | +| TC-05 | protected branch override | `--allow-protected-branch` で実行可 | `allow_protected_branch: true` + Workflow SUCCEEDED | PASS | + +## 3. 主要ログ抜粋 + +### TC-01 (`out-default.log`) +- `effective_base_branch: feature/verify` +- `effective_base_branch_source: current` +- `Workflow: SUCCEEDED` + +### TC-02 (`out-drift.log`) +- `Branch drift detected before step "should_not_run" ...` +- `Workflow: FAILED` + +### TC-03 (`out-transition.log`) +- `branch_transition_step: branch` +- `Workflow: SUCCEEDED` + +### TC-04 (`out-protected-block.log`) +- `Error: blocked: expected_work_branch "main" is protected ...` + +### TC-05 (`out-protected-allow.log`) +- `allow_protected_branch: true` +- `[workflow][warn] allow_protected_branch=true ...` +- `Workflow: SUCCEEDED` + +## 4. 総合判定 +- 全 5 ケース PASS +- 仕様書 (`tests/branch/test-spec.md`) 記載の観点を満たした diff --git a/tests/branch/test-spec.md b/tests/branch/test-spec.md new file mode 100644 index 0000000..8c2765c --- /dev/null +++ b/tests/branch/test-spec.md @@ -0,0 +1,74 @@ +# Branch Lock / Base Branch テスト仕様書 + +## 1. 目的 +`BASE_BRANCH` 未指定時の current branch 採用、branch drift の fail-fast、保護ブランチガード、`create_branch=true` 時の遷移許容が仕様どおりに動作することを確認する。 + +## 2. 対象 +- `src/workflow/branch-context.ts` +- `src/workflow/run.ts` +- `src/workflow/executor.ts` +- `src/daemon/daemon.ts`(本スクリプトは `workflow run` を直接実行) + +## 3. 前提条件 +- `bun` と `git` が利用可能 +- このリポジトリのルートで `bun run src/workflow/run.ts ...` を実行可能 +- `/tmp` 配下に検証用ディレクトリを作成可能 + +## 4. テスト観点 +1. `BASE_BRANCH` 未指定時に `startup_branch` が `effective_base_branch` になる +2. step 間でブランチが変更された場合、次 step 開始前に drift 検知して停止する +3. `create_branch=true` かつ `branch_transition_step` 指定時、遷移後ブランチで継続できる +4. `create_branch=false` で保護ブランチ(`main`)実行時は fail-fast する +5. `--allow-protected-branch` 指定時のみ保護ブランチ実行を許可する + +## 5. テストケース + +### TC-01: current branch 既定採用 +- 条件: `feature/verify` 上で `create_branch=false` +- 手順: `wf-default.yaml` を実行 +- 期待結果: + - 実行成功 + - ログに `effective_base_branch: feature/verify` + - ログに `effective_base_branch_source: current` + +### TC-02: branch drift fail-fast +- 条件: 1つ目の step で `git checkout main` を実行 +- 手順: `wf-drift.yaml` を実行 +- 期待結果: + - 実行失敗 + - `should_not_run` step の開始前に `Branch drift detected` が出力される + +### TC-03: branch transition 許容 +- 条件: `create_branch=true`, `branch_transition_step=branch` +- 手順: `wf-transition.yaml` を実行 +- 期待結果: + - 実行成功 + - `branch` step で作成した `feature/transition-pass` 上で `work` step が実行される + +### TC-04: protected branch ブロック +- 条件: `main` 上で `create_branch=false` +- 手順: `wf-protected.yaml` を実行(override なし) +- 期待結果: + - 実行失敗 + - `expected_work_branch "main" is protected` エラー + +### TC-05: protected branch override 許可 +- 条件: TC-04 と同条件 +- 手順: `wf-protected.yaml` を `--allow-protected-branch` 付きで実行 +- 期待結果: + - 実行成功 + - ログに `allow_protected_branch: true` + +## 6. 実行方法 +```bash +bash tests/branch/run-branch-verification.sh +``` + +引数で検証ディレクトリを指定可能: +```bash +bash tests/branch/run-branch-verification.sh /tmp/my-branch-verify +``` + +## 7. 自動実行 +- Makefile ターゲット: `make test-branch` +- CI ワークフロー: `.github/workflows/ci.yml`(`make test-all` 内で `test-branch` を実行) diff --git a/tests/cli-test-plan.md b/tests/cli-test-plan.md new file mode 100644 index 0000000..2b96fd3 --- /dev/null +++ b/tests/cli-test-plan.md @@ -0,0 +1,109 @@ +# CLI/バイナリ統合 テスト計画(roboppi) + +## 1. 目的 +- 単一バイナリ `roboppi` に統合したコマンド体系(server/run/workflow/daemon/agent)が仕様どおりに動作することを確認する。 +- supervised 実行(Supervisor -> Core -> Worker)が、dev(`bun run`)/ compiled(`./roboppi`)双方で成立することを確認する。 +- `RangeError: Out of memory` の再発防止として、低メモリ環境でのビルド/実行手順と検証観点を明文化する。 + +## 2. 対象(スコープ) +- CLI ルーティング/サブコマンド: `src/cli.ts` +- Core 起動(stdio/socket/tcp): `src/core/core-runtime.ts`, `src/index.ts` +- Workflow runner(`roboppi workflow`): `src/workflow/run.ts`(`runWorkflowCli`) +- Daemon runner(`roboppi daemon`): `src/daemon/cli.ts`(`runDaemonCli`) +- supervised child の起動方式: `src/scheduler/supervisor.ts` +- ビルド/配布: `Makefile`, `package.json`(`bin`) + +## 3. 非対象 +- 外部 worker CLI(OpenCode/Claude/Codex)の正当性・安定性(本プロジェクト外) +- OS 依存の watcher/webhook の詳細な網羅(必要に応じて個別計画を追加) + +## 4. テスト戦略 +### 4.1 自動テスト(CI で回す) +- 既存: `make typecheck`, `make test`, `make test-at`, `make test-branch`, `make test-all` +- 追加(推奨): `tests/at/cli-subcommands.test.ts` を新設し、`bun run src/cli.ts workflow ...` 等の起動を最低限カバー + +### 4.2 手動/任意(ローカルで回す) +- compiled バイナリの E2E(`make build` -> `./roboppi ...`) +- 外部 worker CLI を使う `roboppi run` 成功系 + +## 5. 前提条件 +- `bun` が利用可能 +- workflow の `CUSTOM` step 実行のため `bash` が利用可能 +- `/tmp` へのディレクトリ作成が可能 +- daemon の短時間起動検証で `timeout` が使える(Linux)。macOS は `gtimeout` か Ctrl-C で代替 + +## 6. テスト観点 +- サブコマンド分岐: `roboppi workflow`/`roboppi daemon`/`roboppi run`/`roboppi agent` +- `agent` は `run` のエイリアスとして同等のエラー/終了コードになる +- Core IPC: stdout(または socket)には JSON Lines のみ、ログは stderr +- supervised transport: `AGENTCORE_SUPERVISED_IPC_TRANSPORT` / `ROBOPPI_SUPERVISED_IPC_TRANSPORT` の解決 +- compiled 時の Core 起動: Core entrypoint が `process.execPath`(=同一バイナリ)でも動作する +- OOM 対策: `BUN_FLAGS=--smol`(ビルド/テスト時)、`BUN_BUILD_FLAGS=--compile-exec-argv=--smol`(compiled の子 Core) + +## 7. テストケース + +### TC-CLI-01: ルート help +- コマンド: `bun run src/cli.ts --help` +- 期待: Usage に `run`/`workflow`/`daemon`/`agent` が表示される、exit code = 0 + +### TC-CLI-02: workflow help +- コマンド: `bun run src/cli.ts workflow --help` +- 期待: `roboppi workflow ` の Usage が表示される、exit code = 0 + +### TC-CLI-03: daemon help +- コマンド: `bun run src/cli.ts daemon --help` +- 期待: `roboppi daemon ` の Usage が表示される、exit code = 0 + +### TC-CLI-04: agent は run のエイリアス +- コマンド: `bun run src/cli.ts run` と `bun run src/cli.ts agent` をそれぞれ(必須引数なしで)実行 +- 期待: 両者とも `--worker is required` エラーになり、exit code != 0 + +### TC-WF-01: workflow(supervised 既定)で hello-world を実行 +- コマンド: `bun run src/cli.ts workflow examples/hello-world.yaml --verbose` +- 期待: + - exit code = 0 + - 結果に `PASS greet` が含まれる + +### TC-WF-02: workflow(direct)で hello-world を実行 +- コマンド: `bun run src/cli.ts workflow examples/hello-world.yaml --direct --verbose` +- 期待: + - exit code = 0 + - 結果に `PASS greet` が含まれる + +### TC-DMN-01: daemon を短時間起動して即停止できる +- コマンド(Linux): + - `timeout 2s bun run src/cli.ts daemon examples/daemon/simple-cron.yaml --workspace examples/daemon --direct --verbose` +- 期待: + - パース/起動ログが出る(例: `Event loop started`) + - 例外で落ちない + +### TC-SRV-01: Core IPC server の最低限の健全性 +- 手順: + 1) `bun run src/cli.ts` をサブプロセスとして起動(stdin/stdout を pipe) + 2) `submit_job` を JSONL で投入 + 3) `ack`(または `error`)が JSONL で返ることを確認 +- 期待: + - stdout に非 JSON が混入しない + - exit code が異常終了しない + +### TC-BIN-01: compiled バイナリで workflow が動く(任意) +- コマンド: + - `make clean && make build` + - `./roboppi workflow examples/hello-world.yaml --verbose` +- 期待: exit code = 0 + +### TC-MEM-01: 低メモリ向けビルド(任意/再現時) +- コマンド: + - `make clean && make build BUN_FLAGS=--smol` +- 期待: `RangeError: Out of memory` を回避してビルドが完了する + +### TC-MEM-02: compiled の子 Core を smol で動かす(任意/再現時) +- コマンド: + - `make clean && make build BUN_BUILD_FLAGS=--compile-exec-argv=--smol` + - `./roboppi workflow examples/hello-world.yaml --verbose` +- 期待: supervised 実行が `Out of memory` で停止しない + +## 8. 実行方法(まとめ) +- 自動(推奨): `make test-all` +- 変更点(CLI 統合)を重点確認: `bun run src/cli.ts workflow examples/hello-world.yaml --verbose` +- compiled E2E: `make build && ./roboppi workflow examples/hello-world.yaml --verbose`