diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..8bc6ddb --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,5 @@ +extends: + - '@commitlint/config-conventional' + +rules: + header-max-length: [0, 'always', 100] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5f7036b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.gen.go -diff linguist-generated=true +*.gen.json -diff linguist-generated=true +**/mocks/** -diff linguist-generated=true diff --git a/.github/scripts/compute_release.py b/.github/scripts/compute_release.py new file mode 100644 index 0000000..b3a63c2 --- /dev/null +++ b/.github/scripts/compute_release.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Compute the next stable tag for one module in this repository.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +MODULES = ( + "codexapp", + "config", + "debugserver", + "di", + "filesystem", + "health", + "healthotel", + "healthserver", + "healthzap", + "lifecycle", + "log", + "oapivalidator", + "postgresdb", + "sqlitedb", + "telemetry", + "txmanager", +) +BUMPS = ("patch", "minor", "major") +Runner = Callable[[list[str]], subprocess.CompletedProcess[str]] + + +def run_command(arguments: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + arguments, + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + + +def stable_tags(module: str, runner: Runner = run_command) -> list[tuple[tuple[int, int, int], str]]: + result = runner(["git", "tag", "--list", f"{module}/v*"]) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "failed to list git tags") + pattern = re.compile(rf"^{re.escape(module)}/v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + tags: list[tuple[tuple[int, int, int], str]] = [] + for candidate in result.stdout.splitlines(): + match = pattern.fullmatch(candidate.strip()) + if match: + tags.append((tuple(map(int, match.groups())), candidate.strip())) + return sorted(tags) + + +def bump_version(version: tuple[int, int, int], bump: str) -> tuple[int, int, int]: + major, minor, patch = version + if bump == "major": + return major + 1, 0, 0 + if bump == "minor": + return major, minor + 1, 0 + if bump == "patch": + return major, minor, patch + 1 + raise ValueError(f"unsupported bump: {bump}") + + +def compute_release(module: str, bump: str, runner: Runner = run_command) -> tuple[str, str]: + if module not in MODULES: + raise ValueError(f"unsupported module: {module}") + if bump not in BUMPS: + raise ValueError(f"unsupported bump: {bump}") + tags = stable_tags(module, runner) + previous_version, previous_tag = tags[-1] if tags else ((0, 0, 0), "") + next_version = bump_version(previous_version, bump) + next_tag = f"{module}/v{next_version[0]}.{next_version[1]}.{next_version[2]}" + return previous_tag, next_tag + + +def require_new_commits(module: str, previous_tag: str, runner: Runner = run_command) -> None: + revision = f"{previous_tag}..HEAD" if previous_tag else "HEAD" + result = runner(["git", "rev-list", "--count", revision, "--", module]) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "failed to inspect module commits") + if int(result.stdout.strip()) == 0: + raise RuntimeError(f"no unreleased commits touch {module}/") + + +def require_absent_tag(tag: str, runner: Runner = run_command) -> None: + local = runner(["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"]) + if local.returncode == 0: + raise RuntimeError(f"tag already exists locally: {tag}") + if local.returncode != 1: + raise RuntimeError(local.stderr.strip() or "failed to inspect local tags") + remote = runner(["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"]) + if remote.returncode == 0: + raise RuntimeError(f"tag already exists on origin: {tag}") + if remote.returncode not in (2,): + raise RuntimeError(remote.stderr.strip() or "failed to inspect remote tags") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--module", required=True, choices=MODULES) + parser.add_argument("--bump", required=True, choices=BUMPS) + parser.add_argument("--previous", action="store_true", help="print the previous stable tag") + parser.add_argument("--require-new-commits", action="store_true") + parser.add_argument("--require-absent-tag", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + previous_tag, next_tag = compute_release(args.module, args.bump) + if args.require_new_commits: + require_new_commits(args.module, previous_tag) + if args.require_absent_tag: + require_absent_tag(next_tag) + except (RuntimeError, ValueError) as error: + print(error, file=sys.stderr) + return 1 + print(previous_tag if args.previous else next_tag) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/tests/__init__.py b/.github/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.github/scripts/tests/test_compute_release.py b/.github/scripts/tests/test_compute_release.py new file mode 100644 index 0000000..ecce0ff --- /dev/null +++ b/.github/scripts/tests/test_compute_release.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import subprocess +import unittest + +from compute_release import bump_version, compute_release, require_absent_tag, require_new_commits + + +def completed(stdout: str = "", returncode: int = 0, stderr: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], returncode, stdout, stderr) + + +class ReleaseVersionTests(unittest.TestCase): + def test_initial_minor_release_is_v0_1_0(self) -> None: + previous, next_tag = compute_release("health", "minor", lambda _: completed()) + self.assertEqual("", previous) + self.assertEqual("health/v0.1.0", next_tag) + + def test_bumps_latest_stable_tag_and_ignores_nonstable_tags(self) -> None: + tags = "health/v0.2.9\nhealth/v0.3.0-rc.1\nhealth/v0.10.1\nother/v9.0.0\n" + previous, next_tag = compute_release("health", "patch", lambda _: completed(tags)) + self.assertEqual("health/v0.10.1", previous) + self.assertEqual("health/v0.10.2", next_tag) + + def test_all_bump_kinds_reset_lower_components(self) -> None: + self.assertEqual((2, 0, 0), bump_version((1, 2, 3), "major")) + self.assertEqual((1, 3, 0), bump_version((1, 2, 3), "minor")) + self.assertEqual((1, 2, 4), bump_version((1, 2, 3), "patch")) + + def test_rejects_unknown_module(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported module"): + compute_release("unknown", "patch", lambda _: completed()) + + def test_requires_module_commits_after_previous_tag(self) -> None: + calls: list[list[str]] = [] + + def runner(arguments: list[str]) -> subprocess.CompletedProcess[str]: + calls.append(arguments) + return completed("0\n") + + with self.assertRaisesRegex(RuntimeError, "no unreleased commits"): + require_new_commits("health", "health/v0.1.0", runner) + self.assertEqual( + ["git", "rev-list", "--count", "health/v0.1.0..HEAD", "--", "health"], + calls[0], + ) + + def test_rejects_existing_remote_tag(self) -> None: + responses = iter((completed(returncode=1), completed("tag\n"))) + with self.assertRaisesRegex(RuntimeError, "already exists on origin"): + require_absent_tag("health/v0.1.0", lambda _: next(responses)) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..55831ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_call: + inputs: + force_go_ci: + description: Run Go checks regardless of changed paths. + type: boolean + default: false + run_postgres_integration: + description: Run PostgreSQL integration tests regardless of changed paths. + type: boolean + default: false + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + go_ci: ${{ steps.filter.outputs.go_ci }} + postgres: ${{ steps.filter.outputs.postgres }} + steps: + - uses: actions/checkout@v6 + - name: Detect changed paths + id: filter + if: ${{ !inputs.force_go_ci && !inputs.run_postgres_integration }} + uses: dorny/paths-filter@v4 + with: + filters: | + go_ci: + - '**/*.go' + - '**/go.mod' + - '**/go.sum' + - 'go.work' + - 'go.work.sum' + - '.golangci.yml' + - 'mise.toml' + - 'mise.lock' + - '.github/workflows/**' + - '.github/scripts/**' + postgres: + - 'postgresdb/**' + - 'txmanager/**' + - 'go.work' + - 'go.work.sum' + - '.github/workflows/ci.yml' + + quality: + needs: changes + if: inputs.force_go_ci || needs.changes.outputs.go_ci == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 + - name: Lint + run: mise run lint + - name: Check generated code + run: mise run check-generated + - name: Test with race detector + run: mise run test:race + - name: Test release helper + run: python3 -m unittest discover -s .github/scripts/tests -t .github/scripts + + postgres-integration: + needs: changes + if: inputs.run_postgres_integration || needs.changes.outputs.postgres == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 + - name: Run PostgreSQL integration tests + run: mise run postgresdb:test-integration + + gate: + if: always() + needs: [changes, quality, postgres-integration] + runs-on: ubuntu-latest + steps: + - name: Verify CI result + env: + CHANGES_RESULT: ${{ needs.changes.result }} + QUALITY_RESULT: ${{ needs.quality.result }} + POSTGRES_RESULT: ${{ needs.postgres-integration.result }} + run: | + for result in "$CHANGES_RESULT" "$QUALITY_RESULT" "$POSTGRES_RESULT"; do + case "$result" in + success|skipped) ;; + *) exit 1 ;; + esac + done diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..88bdf0e --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,42 @@ +name: Commit checks + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: commitlint-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + commitlint: + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]') + || (github.event_name == 'push' && github.event.head_commit.author.name != 'dependabot[bot]') + runs-on: ubuntu-latest + steps: + - name: Enforce single-commit PRs + if: github.event_name == 'pull_request' + env: + PR_COMMIT_COUNT: ${{ github.event.pull_request.commits }} + run: test "$PR_COMMIT_COUNT" -eq 1 + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Install commitlint + run: npm install --no-save --no-package-lock @commitlint/cli @commitlint/config-conventional + - name: Validate PR title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: printf '%s\n' "$PR_TITLE" | npx commitlint --verbose + - name: Validate last commit + run: npx commitlint --last --verbose diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml new file mode 100644 index 0000000..f01abd6 --- /dev/null +++ b/.github/workflows/preview-release.yml @@ -0,0 +1,70 @@ +name: Preview module release + +on: + workflow_dispatch: + inputs: + module: + description: Module to preview. + required: true + type: choice + options: [codexapp, config, debugserver, di, filesystem, health, healthotel, healthserver, healthzap, lifecycle, log, oapivalidator, postgresdb, sqlitedb, telemetry, txmanager] + bump: + description: Stable semantic-version increment. + required: true + default: patch + type: choice + options: [patch, minor, major] + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + env: + MODULE: ${{ inputs.module }} + BUMP: ${{ inputs.bump }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + fetch-tags: true + - uses: jdx/mise-action@v4 + - name: Compute release + id: release + run: | + set -euo pipefail + next_tag="$(python3 .github/scripts/compute_release.py --module "$MODULE" --bump "$BUMP" --require-new-commits)" + previous_tag="$(python3 .github/scripts/compute_release.py --module "$MODULE" --bump "$BUMP" --previous)" + echo "next_tag=$next_tag" >> "$GITHUB_OUTPUT" + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" + - name: Generate release notes + env: + NEXT_TAG: ${{ steps.release.outputs.next_tag }} + PREVIOUS_TAG: ${{ steps.release.outputs.previous_tag }} + run: | + set -euo pipefail + range=() + if [[ -n "$PREVIOUS_TAG" ]]; then + range=("$PREVIOUS_TAG..HEAD") + fi + git-cliff "${range[@]}" \ + --include-path "$MODULE/**" \ + --tag-pattern "^${MODULE}/v[0-9]+\\.[0-9]+\\.[0-9]+$" \ + --tag "$NEXT_TAG" \ + --output release-notes.md + - name: Publish preview summary + env: + NEXT_TAG: ${{ steps.release.outputs.next_tag }} + PREVIOUS_TAG: ${{ steps.release.outputs.previous_tag }} + run: | + { + echo "# Release preview" + echo + echo "- Module: \`$MODULE\`" + echo "- Previous tag: \`${PREVIOUS_TAG:-none}\`" + echo "- Next tag: \`$NEXT_TAG\`" + echo + cat release-notes.md + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fffe397 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,92 @@ +name: Release module + +on: + workflow_dispatch: + inputs: + module: + description: Module to release. + required: true + type: choice + options: [codexapp, config, debugserver, di, filesystem, health, healthotel, healthserver, healthzap, lifecycle, log, oapivalidator, postgresdb, sqlitedb, telemetry, txmanager] + bump: + description: Stable semantic-version increment. + required: true + default: patch + type: choice + options: [patch, minor, major] + +permissions: + contents: write + +concurrency: + group: release-${{ inputs.module }} + cancel-in-progress: false + +jobs: + guard: + runs-on: ubuntu-latest + steps: + - name: Require the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SELECTED_BRANCH: ${{ github.ref_name }} + run: test "$SELECTED_BRANCH" = "$DEFAULT_BRANCH" + + validate: + needs: guard + uses: ./.github/workflows/ci.yml + with: + force_go_ci: true + run_postgres_integration: ${{ inputs.module == 'postgresdb' || inputs.module == 'txmanager' }} + + publish: + needs: validate + runs-on: ubuntu-latest + env: + MODULE: ${{ inputs.module }} + BUMP: ${{ inputs.bump }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + fetch-tags: true + - uses: jdx/mise-action@v4 + - name: Compute release + id: release + run: | + set -euo pipefail + next_tag="$(python3 .github/scripts/compute_release.py --module "$MODULE" --bump "$BUMP" --require-new-commits --require-absent-tag)" + previous_tag="$(python3 .github/scripts/compute_release.py --module "$MODULE" --bump "$BUMP" --previous)" + echo "next_tag=$next_tag" >> "$GITHUB_OUTPUT" + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" + - name: Verify module outside the workspace + run: | + cd "$MODULE" + GOWORK=off go mod download + GOWORK=off go test -race ./... + - name: Generate release notes + env: + NEXT_TAG: ${{ steps.release.outputs.next_tag }} + PREVIOUS_TAG: ${{ steps.release.outputs.previous_tag }} + run: | + set -euo pipefail + range=() + if [[ -n "$PREVIOUS_TAG" ]]; then + range=("$PREVIOUS_TAG..HEAD") + fi + git-cliff "${range[@]}" \ + --include-path "$MODULE/**" \ + --tag-pattern "^${MODULE}/v[0-9]+\\.[0-9]+\\.[0-9]+$" \ + --tag "$NEXT_TAG" \ + --output release-notes.md + - name: Publish GitHub release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.release.outputs.next_tag }} + target_commitish: ${{ github.sha }} + name: ${{ steps.release.outputs.next_tag }} + body_path: release-notes.md + draft: false + prerelease: false + make_latest: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2077952 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Go build artifacts +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Go test and coverage artifacts +*.test +*.out +*.coverprofile + +# Python helper test artifacts +__pycache__/ +*.pyc diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..203c995 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,73 @@ +version: "2" + +linters: + disable: + - errcheck + enable: + - dogsled + - gochecknoinits + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - nolintlint + - paralleltest + - testifylint + - staticcheck + - gosec + - misspell + - nestif + - prealloc + - revive + - unconvert + - unparam + exclusions: + paths: + - ".*_easyjson\\.go$" + rules: + - linters: + - gosec + path: _test\.go + - linters: + - gosec + text: "G404" # math/rand is allowed where pseudo-randomness is intentional. + - linters: + - gosec + text: "G115" # Integer conversions are reviewed at their owning boundary. + - linters: + - funlen + - goconst + path: _test\.go + settings: + revive: + severity: warning + confidence: 0.8 + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: empty-block + - name: superfluous-else + - name: unhandled-error + - name: unused-receiver + - name: unreachable-code + - name: range-val-in-closure + - name: range-val-address + - name: waitgroup-by-value + - name: atomic + - name: early-return + - name: unconditional-recursion + - name: identical-branches + +formatters: + enable: + - gofmt + - goimports + +run: + go: "1.25" + timeout: 3m diff --git a/README.md b/README.md new file mode 100644 index 0000000..159f493 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# go-libs + +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![CI](https://github.com/devctllabs/go-libs/actions/workflows/ci.yml/badge.svg)](https://github.com/devctllabs/go-libs/actions/workflows/ci.yml) + +`go-libs` is a monorepo of focused Go modules for building and operating services. Each +top-level module is versioned independently with tags named `/vX.Y.Z`. + +## Installation + +Install only the module you need: + +```sh +go get github.com/devctllabs/go-libs/@latest +``` + +## Modules + +### Application foundations + +| Module | Description | +| --- | --- | +| [`config`](config) | Ordered, composable configuration loaders for defaults, files, dotenv data, and environment variables. | +| [`di`](di) | A small, type-safe dependency container with explicit resource ownership and shutdown. | +| [`lifecycle`](lifecycle) | Coordination for long-running tasks and graceful shutdown. | + +### Operations and observability + +| Module | Description | +| --- | --- | +| [`debugserver`](debugserver) | A standalone HTTP server for Go pprof endpoints. | +| [`log`](log) | Production JSON zap logger construction without global logger state. | +| [`telemetry`](telemetry) | Instance-owned OpenTelemetry trace and metric providers for Go services. | + +### Health + +| Module | Description | +| --- | --- | +| [`health`](health) | Transport-neutral liveness and readiness probes. | +| [`healthotel`](healthotel) | OpenTelemetry metrics for health check observations. | +| [`healthserver`](healthserver) | OpenAPI-generated Echo endpoints for liveness and readiness probes. | +| [`healthzap`](healthzap) | Structured zap logging for health check failures and recoveries. | + +### Data and infrastructure + +| Module | Description | +| --- | --- | +| [`filesystem`](filesystem) | Rooted filesystem operations that compose with the standard `io/fs` package. | +| [`oapivalidator`](oapivalidator) | OpenAPI request validation middleware for Echo. | +| [`postgresdb`](postgresdb) | Instrumented pgx reader and writer pools for PostgreSQL. | +| [`sqlitedb`](sqlitedb) | Instrumented SQLite reader and writer endpoints. | +| [`txmanager`](txmanager) | Shared transaction boundaries for services and database adapters. | + +### Codex integration + +| Module | Description | +| --- | --- | +| [`codexapp`](codexapp) | A process-owning Go client for the Codex App Server JSONL JSON-RPC protocol. See its [package guide](codexapp/README.md). | + +## Development + +The repository pins its development tools with `mise`: + +```sh +mise install +mise run lint +mise run test:race +mise run check-generated +``` + +Run the PostgreSQL integration suite separately when changing `postgresdb` or `txmanager`: + +```sh +mise run postgresdb:test-integration +``` + +## Releases + +Modules are released independently. Release notes are generated on demand and tags follow the +`/vX.Y.Z` convention. See [RELEASING.md](RELEASING.md) for the release process and module +dependency order. + +## License + +Licensed under the [MIT License](LICENSE). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..d21d006 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,26 @@ +# Releasing modules + +This repository contains independent public Go modules. A release is a stable GitHub release and tag named `/vX.Y.Z`; release notes are generated on demand and no changelog file is committed. + +## Repository settings + +Protect `main`, require the `CI / gate` and `Commit checks / commitlint` checks, and allow squash merging only. Pull requests must contain one commit; both the PR title and the last commit must follow Conventional Commits. + +## Bootstrap order + +Release `health` and `txmanager` before the modules that depend on them: + +- `health` before `healthotel`, `healthserver`, and `healthzap`. +- `txmanager` before `postgresdb` and `sqlitedb`. + +Workspace replacements make local monorepo development possible, but the release workflow tests the selected module with `GOWORK=off`. Therefore a dependent module cannot be published until its declared internal dependency exists publicly. + +## Preview and publish + +1. Run **Preview module release** on the default branch and choose the module plus `patch`, `minor`, or `major`. +2. Review the computed tag and consumer-focused git-cliff notes in the workflow summary. +3. Run **Release module** with the same inputs. + +When a module has no stable tag, its base version is `v0.0.0`; for example, an initial `minor` release becomes `v0.1.0`. Publishing refuses an existing tag and refuses a release when no new commit touches the selected module directory. + +Releases are stable only: there are no release candidates and no binary artifacts for library modules. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..568be95 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,29 @@ +# https://git-cliff.org/docs/configuration + +[changelog] +body = """ +{% for group, commits in commits | group_by(attribute="group") %} +## {{ group | striptags | trim }} + +{% for commit in commits -%} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} +{% endfor -%} + +{% endfor %} +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "New features" }, + { message = "^fix", group = "Bug fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^(build|ci|test|chore)", skip = true }, + { message = ".*", group = "Other changes" }, +] +sort_commits = "oldest" +topo_order = false diff --git a/codexapp/README.md b/codexapp/README.md new file mode 100644 index 0000000..9c1ecf6 --- /dev/null +++ b/codexapp/README.md @@ -0,0 +1,47 @@ +# codexapp + +`codexapp` embeds Codex App Server as a process-owned, bidirectional JSON-RPC dependency. It is +not an HTTP/OpenAPI client: App Server speaks newline-delimited JSON-RPC over stdio and can send +notifications and approval/input requests back to the host. + +The package intentionally separates three compatibility surfaces: + +- selected Codex JSON schemas are checked in under `internal/protocol/schema`; +- quicktype output is internal and records the generating Codex CLI version; +- the public package exposes small stable projections instead of generated DTOs. + +## Lifecycle + +`Open` starts one process and `Client.Close` stops it. For long-running applications, +`Supervisor.Run` can restart an exited process and `Supervisor.Client` waits for a ready +generation. A restart creates a new session: threads must be resumed explicitly and failed calls +are never replayed. + +Turn notifications are delivered through a bounded per-turn `EventStream`. A slow consumer gets +`ErrEventOverflow`; it cannot block the protocol reader or unrelated RPC responses. A process exit +finishes active handles with `SessionLostError`. + +Server-initiated approval, user-input, permission, and MCP elicitation requests run outside the +reader loop. With no handler, or when a handler fails, the response is fail-closed. + +## Permissions and telemetry + +Beta permission profiles are opt-in through `RequiredCapabilities`. Root paths must be absolute, +filesystem roots are rejected, and `thread/start` receives a deterministic deny-by-default +profile. There is no silent fallback when the capability probe fails. + +Pass explicit OpenTelemetry tracer/meter providers and a propagator through `Telemetry`. Nil +providers are no-op. The package never installs global providers or propagators. + +## Updating the protocol snapshot + +The repository pins Go, Node, Codex CLI, and quicktype in the root `mise.toml`/`mise.lock`. + +```sh +mise run codexapp:generate +mise run codexapp:check-generated +mise run codexapp:test +``` + +Generated files must be refreshed deliberately with the pinned tools and reviewed together with +public mapping changes. diff --git a/codexapp/codexapp.go b/codexapp/codexapp.go new file mode 100644 index 0000000..00d98bc --- /dev/null +++ b/codexapp/codexapp.go @@ -0,0 +1,403 @@ +package codexapp + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "github.com/devctllabs/go-libs/codexapp/internal/protocol" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +// SchemaCodexVersion is the Codex CLI version that produced the checked-in schema snapshot. +const SchemaCodexVersion = protocol.CodexVersion + +// Config defines how the Codex App Server process is opened. +type Config struct { + CodexPath string + ProjectDir string + Env []string + Stderr io.Writer + ClientInfo ClientInfo + RequiredCapabilities []Capability + ServerRequestHandler ServerRequestHandler + Telemetry Telemetry + TurnEventBuffer int + MaxMessageBytes int +} + +// Telemetry supplies instance-scoped OpenTelemetry dependencies. Nil fields use no-op providers +// and W3C Trace Context plus Baggage propagation; package globals are never read or changed. +type Telemetry struct { + TracerProvider trace.TracerProvider + MeterProvider metric.MeterProvider + Propagator propagation.TextMapPropagator +} + +// Capability identifies an optional App Server protocol feature. +type Capability string + +const ( + // CapabilityPermissionProfiles enables beta named permission profiles. + CapabilityPermissionProfiles Capability = "permissionProfiles" +) + +// ClientInfo identifies the caller during the App Server handshake. +type ClientInfo struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Version string `json:"version"` +} + +// ServerInfo describes the initialized Codex App Server process. +type ServerInfo struct { + CodexHome string + PlatformFamily string + PlatformOS string + UserAgent string +} + +// ListModelsRequest controls model catalog pagination. +type ListModelsRequest struct { + Cursor string + Limit int + IncludeHidden bool +} + +// ModelList is one page of models available from Codex. +type ModelList struct { + Models []Model + NextCursor string +} + +// Model is the stable subset of model catalog metadata exposed by this package. +type Model struct { + ID string + Model string + DisplayName string + Description string + DefaultReasoningEffort string + SupportedEfforts []ReasoningEffortOption + IsDefault bool + Hidden bool + SupportsPersonality bool +} + +// ReasoningEffortOption describes one reasoning effort supported by a model. +type ReasoningEffortOption struct { + Effort string + Description string +} + +// Client is a connection to one Codex App Server process. +type Client struct { + info ServerInfo + command *exec.Cmd + stdin io.WriteCloser + writeMu sync.Mutex + nextID atomic.Int64 + pendingMu sync.Mutex + pending map[int64]chan rpcResponse + closeOnce sync.Once + doneOnce sync.Once + done chan struct{} + terminalMu sync.Mutex + terminalErr error + processDone chan struct{} + waitMu sync.Mutex + waitErr error + turnsMu sync.Mutex + turns map[string]*TurnHandle + starting map[string]*TurnHandle + capabilities map[Capability]bool + serverRequestHandler ServerRequestHandler + handlerCtx context.Context + handlerCancel context.CancelFunc + instrumentation *instrumentation + generation int64 + eventBuffer int + maxMessageBytes int + stderrTail *tailWriter + profiles map[string]normalizedPermissionProfile +} + +// ServerInfo returns immutable metadata from the initialize response. +func (c *Client) ServerInfo() ServerInfo { + return c.info +} + +// Generation identifies the process generation. Standalone clients use generation one. +func (c *Client) Generation() int64 { return c.generation } + +// ListModels returns one page of models advertised by App Server. +func (c *Client) ListModels(ctx context.Context, request ListModelsRequest) (ModelList, error) { + params := modelListParams{IncludeHidden: &request.IncludeHidden} + if request.Cursor != "" { + params.Cursor = &request.Cursor + } + if request.Limit > 0 { + limit := int64(request.Limit) + params.Limit = &limit + } + var response protocol.ModelListResponse + if err := c.call(ctx, "model/list", params, &response); err != nil { + return ModelList{}, fmt.Errorf("codexapp: list models: %w", err) + } + models := make([]Model, 0, len(response.Data)) + for _, model := range response.Data { + efforts := make([]ReasoningEffortOption, 0, len(model.SupportedReasoningEfforts)) + for _, effort := range model.SupportedReasoningEfforts { + efforts = append(efforts, ReasoningEffortOption{ + Effort: effort.ReasoningEffort, + Description: effort.Description, + }) + } + models = append(models, Model{ + ID: model.ID, + Model: model.Model, + DisplayName: model.DisplayName, + Description: model.Description, + DefaultReasoningEffort: model.DefaultReasoningEffort, + SupportedEfforts: efforts, + IsDefault: model.IsDefault, + Hidden: model.Hidden, + SupportsPersonality: model.SupportsPersonality != nil && *model.SupportsPersonality, + }) + } + result := ModelList{Models: models} + if response.NextCursor != nil { + result.NextCursor = *response.NextCursor + } + return result, nil +} + +// Close stops the owned App Server process and waits for it to exit. +func (c *Client) Close(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("codexapp: context is required") + } + c.closeOnce.Do(func() { + _ = c.stdin.Close() + }) + select { + case <-c.processDone: + c.waitMu.Lock() + defer c.waitMu.Unlock() + return c.waitErr + case <-ctx.Done(): + _ = c.command.Process.Kill() + <-c.processDone + return ctx.Err() + } +} + +// Open starts and initializes a Codex App Server client. +func Open(ctx context.Context, cfg Config) (*Client, error) { + if ctx == nil { + return nil, fmt.Errorf("codexapp: context is required") + } + var err error + cfg, err = normalizeConfig(cfg) + if err != nil { + return nil, err + } + capabilities, err := requiredCapabilities(cfg.RequiredCapabilities) + if err != nil { + return nil, err + } + instrumentation, err := newInstrumentation(cfg.Telemetry) + if err != nil { + return nil, fmt.Errorf("codexapp: create telemetry instruments: %w", err) + } + process, err := startAppServer(cfg) + if err != nil { + return nil, err + } + client := newClient(cfg, capabilities, instrumentation, process) + go client.readLoop(process.stdout, client.maxMessageBytes) + go client.waitForProcess() + if err := client.initializeOpen(ctx, cfg.ClientInfo); err != nil { + client.abortOpen() + return nil, err + } + return client, nil +} + +type appServerProcess struct { + command *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderrTail *tailWriter +} + +func requiredCapabilities(required []Capability) (map[Capability]bool, error) { + capabilities := make(map[Capability]bool, len(required)) + for _, capability := range required { + if capability != CapabilityPermissionProfiles { + return nil, fmt.Errorf("codexapp: unknown required capability %q", capability) + } + capabilities[capability] = true + } + return capabilities, nil +} + +func startAppServer(cfg Config) (*appServerProcess, error) { + path := strings.TrimSpace(cfg.CodexPath) + if path == "" { + var err error + path, err = exec.LookPath("codex") + if err != nil { + return nil, fmt.Errorf("codexapp: find codex executable: %w", err) + } + } + //nolint:gosec // CodexPath is an explicit caller-selected executable. + command := exec.Command(path, "app-server", "--listen", "stdio://") + command.Dir = cfg.ProjectDir + if cfg.Env != nil { + command.Env = append([]string(nil), cfg.Env...) + } + stderrTail := newTailWriter(64 << 10) + command.Stderr = stderrTail + if cfg.Stderr != nil { + command.Stderr = io.MultiWriter(stderrTail, cfg.Stderr) + } + stdin, err := command.StdinPipe() + if err != nil { + return nil, fmt.Errorf("codexapp: open stdin: %w", err) + } + stdout, err := command.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("codexapp: open stdout: %w", err) + } + if err := command.Start(); err != nil { + return nil, fmt.Errorf("codexapp: start app server: %w", err) + } + return &appServerProcess{command: command, stdin: stdin, stdout: stdout, stderrTail: stderrTail}, nil +} + +func newClient( + cfg Config, + capabilities map[Capability]bool, + instrumentation *instrumentation, + process *appServerProcess, +) *Client { + handlerCtx, handlerCancel := context.WithCancel(context.Background()) + return &Client{ + command: process.command, + stdin: process.stdin, + pending: make(map[int64]chan rpcResponse), + done: make(chan struct{}), + processDone: make(chan struct{}), + turns: make(map[string]*TurnHandle), + starting: make(map[string]*TurnHandle), + capabilities: capabilities, + serverRequestHandler: cfg.ServerRequestHandler, + handlerCtx: handlerCtx, + handlerCancel: handlerCancel, + instrumentation: instrumentation, + generation: 1, + eventBuffer: cfg.TurnEventBuffer, + maxMessageBytes: cfg.MaxMessageBytes, + stderrTail: process.stderrTail, + profiles: make(map[string]normalizedPermissionProfile), + } +} + +func (c *Client) waitForProcess() { + err := c.command.Wait() + c.waitMu.Lock() + c.waitErr = err + c.waitMu.Unlock() + terminalErr := io.EOF + if err != nil { + terminalErr = &ProcessExitError{Cause: err, StderrTail: c.stderrTail.String()} + } + c.finish(terminalErr) + close(c.processDone) +} + +func (c *Client) initializeOpen(ctx context.Context, info ClientInfo) error { + if err := c.initialize(ctx, info); err != nil { + return err + } + if !c.capabilities[CapabilityPermissionProfiles] { + return nil + } + var profiles protocol.PermissionProfileListResponse + if err := c.call(ctx, "permissionProfile/list", permissionProfileListParams{}, &profiles); err != nil { + return &UnsupportedCapabilityError{Capability: CapabilityPermissionProfiles, Cause: err} + } + return nil +} + +func (c *Client) abortOpen() { + _ = c.stdin.Close() + _ = c.command.Process.Kill() + <-c.processDone +} + +func normalizeConfig(cfg Config) (Config, error) { + if strings.TrimSpace(cfg.ClientInfo.Name) == "" { + return Config{}, fmt.Errorf("codexapp: client info name is required") + } + if strings.TrimSpace(cfg.ClientInfo.Version) == "" { + return Config{}, fmt.Errorf("codexapp: client info version is required") + } + if cfg.TurnEventBuffer < 0 { + return Config{}, fmt.Errorf("codexapp: turn event buffer must not be negative") + } + if cfg.TurnEventBuffer == 0 { + cfg.TurnEventBuffer = 256 + } + if cfg.MaxMessageBytes < 0 { + return Config{}, fmt.Errorf("codexapp: max message bytes must not be negative") + } + if cfg.MaxMessageBytes == 0 { + cfg.MaxMessageBytes = defaultMaxMessageBytes + } + if cfg.ProjectDir != "" { + absolute, err := filepath.Abs(cfg.ProjectDir) + if err != nil { + return Config{}, fmt.Errorf("codexapp: resolve project directory: %w", err) + } + info, err := os.Stat(absolute) + if err != nil { + return Config{}, fmt.Errorf("codexapp: inspect project directory: %w", err) + } + if !info.IsDir() { + return Config{}, fmt.Errorf("codexapp: project directory %q is not a directory", absolute) + } + cfg.ProjectDir = filepath.Clean(absolute) + } + return cfg, nil +} + +func (c *Client) initialize(ctx context.Context, clientInfo ClientInfo) error { + params := initializeParams{ClientInfo: clientInfo} + if c.capabilities[CapabilityPermissionProfiles] { + enabled := true + params.Capabilities = &initializeCapabilities{ExperimentalAPI: &enabled} + } + var response protocol.InitializeResponse + if err := c.call(ctx, "initialize", params, &response); err != nil { + return fmt.Errorf("codexapp: initialize: %w", err) + } + c.info = ServerInfo{ + CodexHome: response.CodexHome, + PlatformFamily: response.PlatformFamily, + PlatformOS: response.PlatformOS, + UserAgent: response.UserAgent, + } + if err := c.notify("initialized", nil); err != nil { + return fmt.Errorf("codexapp: initialized notification: %w", err) + } + return nil +} diff --git a/codexapp/codexapp_test.go b/codexapp/codexapp_test.go new file mode 100644 index 0000000..83cf177 --- /dev/null +++ b/codexapp/codexapp_test.go @@ -0,0 +1,504 @@ +package codexapp_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/devctllabs/go-libs/codexapp" + "github.com/devctllabs/go-libs/codexapp/mocks" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + oteltrace "go.opentelemetry.io/otel/trace" + "go.uber.org/mock/gomock" +) + +func TestOpenRejectsMissingClientIdentity(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ctx context.Context + config codexapp.Config + message string + }{ + { + name: "name", + ctx: context.Background(), + config: codexapp.Config{}, + message: "client info name", + }, + { + name: "version", + ctx: context.Background(), + config: codexapp.Config{ClientInfo: codexapp.ClientInfo{ + Name: "test-client", + }}, + message: "client info version", + }, + { + name: "context", + config: codexapp.Config{ClientInfo: codexapp.ClientInfo{ + Name: "test-client", + Version: "1.0.0", + }}, + message: "context", + }, + { + name: "event buffer", + ctx: context.Background(), + config: codexapp.Config{ + ClientInfo: codexapp.ClientInfo{Name: "test-client", Version: "1.0.0"}, + TurnEventBuffer: -1, + }, + message: "turn event buffer", + }, + { + name: "message size", + ctx: context.Background(), + config: codexapp.Config{ + ClientInfo: codexapp.ClientInfo{Name: "test-client", Version: "1.0.0"}, + MaxMessageBytes: -1, + }, + message: "max message bytes", + }, + { + name: "project directory", + ctx: context.Background(), + config: codexapp.Config{ + ProjectDir: filepath.Join(t.TempDir(), "missing"), + ClientInfo: codexapp.ClientInfo{Name: "test-client", Version: "1.0.0"}, + }, + message: "project directory", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, err := codexapp.Open(tt.ctx, tt.config) + + require.Nil(t, client) + require.ErrorContains(t, err, tt.message) + }) + } +} + +func TestOpenInitializesAndOwnsAppServerProcess(t *testing.T) { + t.Parallel() + executable, err := os.Executable() + require.NoError(t, err) + + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), "GO_WANT_CODEXAPP_HELPER=initialize"), + ClientInfo: codexapp.ClientInfo{ + Name: "test-client", + Title: "Test Client", + Version: "1.2.3", + }, + }) + require.NoError(t, err) + require.Equal(t, codexapp.ServerInfo{ + CodexHome: "/tmp/codex-home", + PlatformFamily: "unix", + PlatformOS: "test", + UserAgent: "codex-test/1", + }, client.ServerInfo()) + require.NoError(t, client.Close(context.Background())) +} + +func TestClientListsModels(t *testing.T) { + t.Parallel() + client := openHelperClient(t, "models") + + models, err := client.ListModels(context.Background(), codexapp.ListModelsRequest{ + Limit: 2, + IncludeHidden: true, + }) + + require.NoError(t, err) + require.Equal(t, codexapp.ModelList{ + Models: []codexapp.Model{{ + ID: "gpt-test", + Model: "gpt-test", + DisplayName: "GPT Test", + Description: "test model", + DefaultReasoningEffort: "high", + SupportedEfforts: []codexapp.ReasoningEffortOption{{ + Effort: "high", + Description: "Thorough", + }}, + IsDefault: true, + SupportsPersonality: true, + }}, + NextCursor: "next-page", + }, models) +} + +func TestClientRunsTurnAndStreamsCorrelatedEvents(t *testing.T) { + t.Parallel() + client := openHelperClient(t, "turn") + thread, err := client.StartThread(context.Background(), codexapp.StartThreadRequest{ + Settings: codexapp.ThreadSettings{Model: "gpt-test", Cwd: "/workspace"}, + }) + require.NoError(t, err) + require.Equal(t, codexapp.Thread{ + ID: "thread-1", + Model: "gpt-test", + CreatedAt: 10, + UpdatedAt: 11, + }, thread) + + handle, err := client.StartTurn(context.Background(), codexapp.StartTurnRequest{ + ThreadID: thread.ID, + Input: []codexapp.Input{codexapp.Text("hello")}, + }) + require.NoError(t, err) + + event, err := handle.Events().Next(context.Background()) + require.NoError(t, err) + require.Equal(t, codexapp.EventAgentMessageDelta, event.Type) + require.Equal(t, "thread-1", event.ThreadID) + require.Equal(t, "turn-1", event.TurnID) + require.Equal(t, &codexapp.TextDelta{Text: "hello"}, event.TextDelta) + + event, err = handle.Events().Next(context.Background()) + require.NoError(t, err) + require.Equal(t, codexapp.EventTurnCompleted, event.Type) + require.Equal(t, &codexapp.Turn{ID: "turn-1", Status: codexapp.TurnStatusCompleted}, event.Turn) + + _, err = handle.Events().Next(context.Background()) + require.ErrorIs(t, err, io.EOF) + result, err := handle.Wait(context.Background()) + require.NoError(t, err) + require.Equal(t, codexapp.TurnResult{ + ThreadID: "thread-1", + Turn: codexapp.Turn{ID: "turn-1", Status: codexapp.TurnStatusCompleted}, + }, result) +} + +func TestPermissionProfilesAreNegotiatedAndAppliedToThread(t *testing.T) { + t.Parallel() + root := t.TempDir() + readRoot := filepath.Join(root, "read") + writeRoot := filepath.Join(root, "write") + executable, err := os.Executable() + require.NoError(t, err) + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), + "GO_WANT_CODEXAPP_HELPER=permissions", + "CODEXAPP_READ_ROOT="+readRoot, + "CODEXAPP_WRITE_ROOT="+writeRoot, + ), + ClientInfo: codexapp.ClientInfo{Name: "test-client", Title: "Test Client", Version: "1.2.3"}, + RequiredCapabilities: []codexapp.Capability{codexapp.CapabilityPermissionProfiles}, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close(context.Background())) }) + + thread, err := client.StartThread(context.Background(), codexapp.StartThreadRequest{ + Permissions: codexapp.Permissions{ + ReadRoots: []string{writeRoot, readRoot}, + WriteRoots: []string{writeRoot}, + NetworkEnabled: true, + }, + }) + require.NoError(t, err) + require.Equal(t, "thread-permissions", thread.ID) +} + +func TestServerRequestHandlerDoesNotBlockProtocolReader(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + handler := mocks.NewMockServerRequestHandler(ctrl) + release := make(chan struct{}) + invoked := make(chan struct{}) + inboundSpan := make(chan oteltrace.SpanContext, 1) + handler.EXPECT().HandleServerRequest(gomock.Any(), codexapp.ServerRequest{ + Type: codexapp.ServerRequestCommandApproval, + ThreadID: "thread-1", + TurnID: "turn-1", + ItemID: "item-1", + CommandApproval: &codexapp.CommandApprovalRequest{ + Command: "go test ./...", + Cwd: "/workspace", + Reason: "run tests", + AvailableDecisions: []string{"acceptForSession", "decline"}, + }, + }).DoAndReturn(func(ctx context.Context, _ codexapp.ServerRequest) (codexapp.ServerResponse, error) { + inboundSpan <- oteltrace.SpanContextFromContext(ctx) + close(invoked) + <-release + return codexapp.AcceptCommandForSession(), nil + }) + + executable, err := os.Executable() + require.NoError(t, err) + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), "GO_WANT_CODEXAPP_HELPER=server_request"), + ClientInfo: codexapp.ClientInfo{Name: "test-client", Title: "Test Client", Version: "1.2.3"}, + ServerRequestHandler: handler, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close(context.Background())) }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err = client.ListModels(ctx, codexapp.ListModelsRequest{}) + require.NoError(t, err, "a blocked handler must not block an unrelated response") + select { + case <-invoked: + case <-ctx.Done(): + require.NoError(t, ctx.Err()) + } + spanContext := <-inboundSpan + close(release) + require.Equal(t, "0123456789abcdef0123456789abcdef", spanContext.TraceID().String()) + _, err = client.ListModels(ctx, codexapp.ListModelsRequest{}) + require.NoError(t, err) +} + +func TestRPCUsesExplicitOpenTelemetryProvidersAndW3CPropagation(t *testing.T) { + t.Parallel() + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + executable, err := os.Executable() + require.NoError(t, err) + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), "GO_WANT_CODEXAPP_HELPER=telemetry"), + ClientInfo: codexapp.ClientInfo{Name: "test-client", Title: "Test Client", Version: "1.2.3"}, + Telemetry: codexapp.Telemetry{ + TracerProvider: provider, + Propagator: propagation.TraceContext{}, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close(context.Background())) }) + + ctx, parent := provider.Tracer("codexapp-test").Start(context.Background(), "parent") + _, err = client.ListModels(ctx, codexapp.ListModelsRequest{}) + parent.End() + require.NoError(t, err) + + var rpcSpan sdktrace.ReadOnlySpan + for _, span := range recorder.Ended() { + if span.Name() == "codexapp.model/list" { + rpcSpan = span + break + } + } + require.NotNil(t, rpcSpan) + require.Equal(t, parent.SpanContext().SpanID(), rpcSpan.Parent().SpanID()) +} + +func TestCanceledWrittenCallReportsUnknownOutcome(t *testing.T) { + t.Parallel() + client := openHelperClient(t, "cancel") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + _, err := client.ListModels(ctx, codexapp.ListModelsRequest{}) + + require.ErrorIs(t, err, context.DeadlineExceeded) + require.ErrorIs(t, err, codexapp.ErrOutcomeUnknown) + var callErr *codexapp.CallError + require.ErrorAs(t, err, &callErr) + require.Equal(t, "model/list", callErr.Method) + require.True(t, callErr.OutcomeUnknown) +} + +func TestSupervisorRestartsProcessWithoutReplayingFailedCall(t *testing.T) { + t.Parallel() + executable, err := os.Executable() + require.NoError(t, err) + statePath := filepath.Join(t.TempDir(), "generation") + supervisor, err := codexapp.NewSupervisor(codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), + "GO_WANT_CODEXAPP_HELPER=supervisor", + "CODEXAPP_SUPERVISOR_STATE="+statePath, + ), + ClientInfo: codexapp.ClientInfo{Name: "test-client", Title: "Test Client", Version: "1.2.3"}, + }, codexapp.RestartConfig{ + InitialDelay: time.Millisecond, + MaxDelay: 5 * time.Millisecond, + Multiplier: 2, + ResetAfter: time.Minute, + }) + require.NoError(t, err) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + runResult := make(chan error, 1) + go func() { runResult <- supervisor.Run(runCtx) }() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + first, err := supervisor.Client(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, first.Generation()) + _, err = first.ListModels(ctx, codexapp.ListModelsRequest{}) + require.Error(t, err) + + second, err := supervisor.Client(ctx) + require.NoError(t, err) + require.EqualValues(t, 2, second.Generation()) + require.NotSame(t, first, second) + _, err = second.ListModels(ctx, codexapp.ListModelsRequest{}) + require.NoError(t, err) + + require.NoError(t, supervisor.Shutdown(ctx)) + require.NoError(t, <-runResult) +} + +func TestActiveTurnFailsWhenProcessSessionIsLost(t *testing.T) { + t.Parallel() + executable, err := os.Executable() + require.NoError(t, err) + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), "GO_WANT_CODEXAPP_HELPER=crash_turn"), + ClientInfo: codexapp.ClientInfo{Name: "test-client", Title: "Test Client", Version: "1.2.3"}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close(context.Background()) }) + thread, err := client.StartThread(context.Background(), codexapp.StartThreadRequest{}) + require.NoError(t, err) + handle, err := client.StartTurn(context.Background(), codexapp.StartTurnRequest{ + ThreadID: thread.ID, + Input: []codexapp.Input{codexapp.Text("hello")}, + }) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + _, waitErr := handle.Wait(ctx) + var sessionErr *codexapp.SessionLostError + require.ErrorAs(t, waitErr, &sessionErr) + require.EqualValues(t, 1, sessionErr.Generation) + var exitErr *codexapp.ProcessExitError + require.ErrorAs(t, waitErr, &exitErr) + _, streamErr := handle.Events().Next(ctx) + require.ErrorAs(t, streamErr, &sessionErr) +} + +func TestClientResumesReadsAndInterruptsThread(t *testing.T) { + t.Parallel() + client := openHelperClient(t, "thread_lifecycle") + thread, err := client.ResumeThread(context.Background(), codexapp.ResumeThreadRequest{ThreadID: "thread-existing"}) + require.NoError(t, err) + require.Equal(t, "thread-existing", thread.ID) + require.Equal(t, "gpt-test", thread.Model) + thread, err = client.ReadThread(context.Background(), codexapp.ReadThreadRequest{ + ThreadID: "thread-existing", IncludeTurns: true, + }) + require.NoError(t, err) + require.Equal(t, []codexapp.Turn{{ID: "turn-old", Status: codexapp.TurnStatusCompleted}}, thread.Turns) + handle, err := client.StartTurn(context.Background(), codexapp.StartTurnRequest{ + ThreadID: "thread-existing", + Input: []codexapp.Input{codexapp.Text("continue")}, + }) + require.NoError(t, err) + require.NoError(t, handle.Interrupt(context.Background())) + result, err := handle.Wait(context.Background()) + require.NoError(t, err) + require.Equal(t, codexapp.TurnStatusInterrupted, result.Turn.Status) +} + +func TestRPCErrorRemainsTypedThroughPublicMethod(t *testing.T) { + t.Parallel() + client := openHelperClient(t, "rpc_error") + _, err := client.ListModels(context.Background(), codexapp.ListModelsRequest{}) + var rpcErr *codexapp.RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, -32000, rpcErr.Code) + require.Equal(t, "unavailable", rpcErr.Message) + require.JSONEq(t, `{"retry":false}`, string(rpcErr.Data)) +} + +func TestOpenClassifiesMissingRequiredCapability(t *testing.T) { + t.Parallel() + executable, err := os.Executable() + require.NoError(t, err) + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), "GO_WANT_CODEXAPP_HELPER=unsupported_permissions"), + ClientInfo: codexapp.ClientInfo{Name: "test-client", Title: "Test Client", Version: "1.2.3"}, + RequiredCapabilities: []codexapp.Capability{codexapp.CapabilityPermissionProfiles}, + }) + require.Nil(t, client) + var capabilityErr *codexapp.UnsupportedCapabilityError + require.ErrorAs(t, err, &capabilityErr) + require.Equal(t, codexapp.CapabilityPermissionProfiles, capabilityErr.Capability) + var rpcErr *codexapp.RPCError + require.ErrorAs(t, err, &rpcErr) + require.Equal(t, -32601, rpcErr.Code) +} + +func TestProcessExitIncludesBoundedStderrTailAndForwardsStderr(t *testing.T) { + t.Parallel() + executable, err := os.Executable() + require.NoError(t, err) + var stderr bytes.Buffer + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), "GO_WANT_CODEXAPP_HELPER=stderr_exit"), + Stderr: &stderr, + ClientInfo: codexapp.ClientInfo{Name: "test-client", Title: "Test Client", Version: "1.2.3"}, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close(context.Background()) }) + _, err = client.ListModels(context.Background(), codexapp.ListModelsRequest{}) + var exitErr *codexapp.ProcessExitError + require.ErrorAs(t, err, &exitErr) + require.Contains(t, exitErr.StderrTail, "app server exploded") + require.Contains(t, stderr.String(), "app server exploded") +} + +func TestThreadAndTurnSettingsMapToProtocol(t *testing.T) { + t.Parallel() + client := openHelperClient(t, "settings") + thread, err := client.StartThread(context.Background(), codexapp.StartThreadRequest{Settings: codexapp.ThreadSettings{ + ModelProvider: "openai", ApprovalPolicy: codexapp.ApprovalPolicyOnRequest, + BaseInstructions: "base", DeveloperInstructions: "developer", Personality: codexapp.PersonalityPragmatic, + }}) + require.NoError(t, err) + handle, err := client.StartTurn(context.Background(), codexapp.StartTurnRequest{ + ThreadID: thread.ID, Input: []codexapp.Input{codexapp.Text("hello")}, Model: "gpt-test", Cwd: "/workspace", + Effort: "high", Summary: codexapp.ReasoningSummaryConcise, Personality: codexapp.PersonalityFriendly, + OutputSchema: json.RawMessage(`{"type":"object"}`), + }) + require.NoError(t, err) + _, err = handle.Wait(context.Background()) + require.NoError(t, err) +} + +func openHelperClient(t *testing.T, mode string) *codexapp.Client { + t.Helper() + executable, err := os.Executable() + require.NoError(t, err) + client, err := codexapp.Open(context.Background(), codexapp.Config{ + CodexPath: executable, + Env: append(os.Environ(), "GO_WANT_CODEXAPP_HELPER="+mode), + ClientInfo: codexapp.ClientInfo{ + Name: "test-client", + Title: "Test Client", + Version: "1.2.3", + }, + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, client.Close(context.Background())) + }) + return client +} diff --git a/codexapp/doc.go b/codexapp/doc.go new file mode 100644 index 0000000..78ef9d1 --- /dev/null +++ b/codexapp/doc.go @@ -0,0 +1,12 @@ +// Package codexapp provides a process-owning Go client for the Codex App Server JSONL JSON-RPC +// protocol. +// +// Open starts exactly one `codex app-server --listen stdio://` process. Client.Close owns its +// shutdown. Supervisor is optional and restarts process sessions, but deliberately never replays +// a protocol request because a written request may already have taken effect. +// +// Generated protocol DTOs and the selected schema snapshot stay internal. Callers use the stable +// projections, typed inputs, turn handles, event streams, and server-request handler in this +// package. OpenTelemetry providers are supplied explicitly; the package does not read or mutate +// OpenTelemetry globals. +package codexapp diff --git a/codexapp/errors.go b/codexapp/errors.go new file mode 100644 index 0000000..b5dc1ff --- /dev/null +++ b/codexapp/errors.go @@ -0,0 +1,98 @@ +package codexapp + +import ( + "errors" + "fmt" +) + +// ErrOutcomeUnknown marks a call that was written but whose response was not observed. +var ErrOutcomeUnknown = errors.New("codexapp: call outcome is unknown") + +// ErrEventOverflow reports that a consumer did not drain its bounded turn stream in time. +var ErrEventOverflow = errors.New("codexapp: turn event buffer overflow") + +// ErrClosed reports an explicitly closed event stream. +var ErrClosed = errors.New("codexapp: closed") + +// ErrTurnStartInProgress reports a concurrent turn/start for the same thread. +var ErrTurnStartInProgress = errors.New("codexapp: turn start already in progress") + +// ErrPermissionProfileMismatch reports a turn profile different from its thread binding. +var ErrPermissionProfileMismatch = errors.New("codexapp: permission profile does not match thread") + +// CallError reports an RPC failure and whether App Server may have applied the request. +type CallError struct { + Method string + Cause error + OutcomeUnknown bool +} + +// ProcessExitError reports an unexpected App Server process exit. +type ProcessExitError struct { + Cause error + StderrTail string +} + +func (e *ProcessExitError) Error() string { + if e.StderrTail != "" { + return fmt.Sprintf("codexapp: app server exited: %v; stderr: %s", e.Cause, e.StderrTail) + } + return fmt.Sprintf("codexapp: app server exited: %v", e.Cause) +} + +// Unwrap returns the process wait error. +func (e *ProcessExitError) Unwrap() error { return e.Cause } + +// SessionLostError reports that process-owned in-flight state cannot survive a session exit. +type SessionLostError struct { + Generation int64 + Cause error +} + +func (e *SessionLostError) Error() string { + return fmt.Sprintf("codexapp: session generation %d was lost: %v", e.Generation, e.Cause) +} + +// Unwrap returns the process or protocol failure that ended the session. +func (e *SessionLostError) Unwrap() error { return e.Cause } + +// ProtocolError reports malformed or oversized JSONL protocol data. +type ProtocolError struct { + Operation string + Cause error +} + +func (e *ProtocolError) Error() string { + return fmt.Sprintf("codexapp: protocol %s: %v", e.Operation, e.Cause) +} + +// Unwrap returns the decoding or framing error. +func (e *ProtocolError) Unwrap() error { return e.Cause } + +// UnsupportedCapabilityError reports a failed required capability probe. +type UnsupportedCapabilityError struct { + Capability Capability + Cause error +} + +func (e *UnsupportedCapabilityError) Error() string { + return fmt.Sprintf("codexapp: required capability %s is unavailable: %v", e.Capability, e.Cause) +} + +// Unwrap returns the probe error. +func (e *UnsupportedCapabilityError) Unwrap() error { return e.Cause } + +func (e *CallError) Error() string { + if e.OutcomeUnknown { + return fmt.Sprintf("codexapp: %s call outcome is unknown: %v", e.Method, e.Cause) + } + return fmt.Sprintf("codexapp: %s call failed: %v", e.Method, e.Cause) +} + +// Unwrap returns the underlying transport or context error. +func (e *CallError) Unwrap() error { return e.Cause } + +// Is reports the outcome-unknown error category while preserving the underlying cause. +func (e *CallError) Is(target error) bool { + return e.OutcomeUnknown && target == ErrOutcomeUnknown +} diff --git a/codexapp/events.go b/codexapp/events.go new file mode 100644 index 0000000..1211cb7 --- /dev/null +++ b/codexapp/events.go @@ -0,0 +1,151 @@ +package codexapp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sync" +) + +// EventType identifies the payload populated on Event. +type EventType string + +const ( + // EventAgentMessageDelta carries an incremental assistant text fragment. + EventAgentMessageDelta EventType = "item/agentMessage/delta" + // EventTurnCompleted carries the terminal turn projection. + EventTurnCompleted EventType = "turn/completed" + EventItemStarted EventType = "item/started" + EventItemCompleted EventType = "item/completed" + EventPlanDelta EventType = "item/plan/delta" + EventReasoningSummaryDelta EventType = "item/reasoning/summaryTextDelta" + EventReasoningTextDelta EventType = "item/reasoning/textDelta" + EventCommandOutputDelta EventType = "item/commandExecution/outputDelta" + EventTurnDiffUpdated EventType = "turn/diff/updated" + EventTurnPlanUpdated EventType = "turn/plan/updated" + EventError EventType = "error" + EventWarning EventType = "warning" + EventTokenUsageUpdated EventType = "thread/tokenUsage/updated" +) + +// Event is a tagged union. Exactly one matching payload pointer is populated. +type Event struct { + Type EventType + ThreadID string + TurnID string + TextDelta *TextDelta + Turn *Turn + Item *Item + CommandOutput *CommandOutputDelta + PlanDelta *PlanDelta + Diff *DiffUpdate + Plan *PlanUpdate + Failure *FailureEvent + Warning *WarningEvent + TokenUsage *TokenUsageEvent + Raw json.RawMessage +} + +// TextDelta is one incremental text fragment. +type TextDelta struct { + Text string +} + +// Item is the stable identity plus raw schema-versioned item payload. +type Item struct { + ID string + Type string + Raw json.RawMessage +} + +type CommandOutputDelta struct{ Text string } +type PlanDelta struct{ Text string } +type DiffUpdate struct{ Diff string } + +type PlanUpdate struct { + Explanation string + Steps []PlanStep +} + +type PlanStep struct { + Step string + Status string +} + +type FailureEvent struct { + Message string + WillRetry bool +} + +type WarningEvent struct{ Message string } +type TokenUsageEvent struct{ Raw json.RawMessage } + +// EventStream is a bounded stream owned by a TurnHandle. +type EventStream struct { + events chan Event + mu sync.Mutex + closed bool + err error +} + +// Close stops delivery to this consumer. It does not interrupt the underlying turn. +func (s *EventStream) Close() error { + s.finish(ErrClosed) + return nil +} + +func newEventStream(capacity int) *EventStream { + return &EventStream{events: make(chan Event, capacity)} +} + +// Next waits for the next event or returns io.EOF after normal completion. +func (s *EventStream) Next(ctx context.Context) (Event, error) { + if ctx == nil { + return Event{}, fmt.Errorf("codexapp: context is required") + } + select { + case event, ok := <-s.events: + if ok { + return event, nil + } + s.mu.Lock() + err := s.err + s.mu.Unlock() + if err == nil { + return Event{}, io.EOF + } + return Event{}, err + case <-ctx.Done(): + return Event{}, ctx.Err() + } +} + +func (s *EventStream) push(event Event) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return io.ErrClosedPipe + } + select { + case s.events <- event: + return nil + default: + err := ErrEventOverflow + s.err = err + s.closed = true + close(s.events) + return err + } +} + +func (s *EventStream) finish(err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return + } + s.err = err + s.closed = true + close(s.events) +} diff --git a/codexapp/events_test.go b/codexapp/events_test.go new file mode 100644 index 0000000..9e80ffb --- /dev/null +++ b/codexapp/events_test.go @@ -0,0 +1,31 @@ +package codexapp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEventStreamReportsOverflowAfterDrainingQueuedEvents(t *testing.T) { + t.Parallel() + stream := newEventStream(1) + first := Event{Type: EventAgentMessageDelta, TextDelta: &TextDelta{Text: "first"}} + require.NoError(t, stream.push(first)) + + err := stream.push(Event{Type: EventAgentMessageDelta, TextDelta: &TextDelta{Text: "lost"}}) + require.ErrorIs(t, err, ErrEventOverflow) + event, err := stream.Next(context.Background()) + require.NoError(t, err) + require.Equal(t, first, event) + _, err = stream.Next(context.Background()) + require.ErrorIs(t, err, ErrEventOverflow) +} + +func TestEventStreamCloseDoesNotPretendToBeNormalCompletion(t *testing.T) { + t.Parallel() + stream := newEventStream(1) + require.NoError(t, stream.Close()) + _, err := stream.Next(context.Background()) + require.ErrorIs(t, err, ErrClosed) +} diff --git a/codexapp/example_test.go b/codexapp/example_test.go new file mode 100644 index 0000000..606725c --- /dev/null +++ b/codexapp/example_test.go @@ -0,0 +1,35 @@ +package codexapp_test + +import ( + "context" + "log" + + "github.com/devctllabs/go-libs/codexapp" +) + +func ExampleOpen() { + ctx := context.Background() + client, err := codexapp.Open(ctx, codexapp.Config{ + ClientInfo: codexapp.ClientInfo{Name: "my-service", Version: "1.0.0"}, + }) + if err != nil { + log.Print(err) + return + } + defer func() { _ = client.Close(context.Background()) }() + + thread, err := client.StartThread(ctx, codexapp.StartThreadRequest{}) + if err != nil { + log.Print(err) + return + } + handle, err := client.StartTurn(ctx, codexapp.StartTurnRequest{ + ThreadID: thread.ID, + Input: []codexapp.Input{codexapp.Text("Review this repository")}, + }) + if err != nil { + log.Print(err) + return + } + _, _ = handle.Wait(ctx) +} diff --git a/codexapp/go.mod b/codexapp/go.mod new file mode 100644 index 0000000..ccdb733 --- /dev/null +++ b/codexapp/go.mod @@ -0,0 +1,29 @@ +module github.com/devctllabs/go-libs/codexapp + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/mock v0.6.0 +) + +tool go.uber.org/mock/mockgen + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/codexapp/go.sum b/codexapp/go.sum new file mode 100644 index 0000000..00bb8d5 --- /dev/null +++ b/codexapp/go.sum @@ -0,0 +1,52 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/codexapp/helper_process_test.go b/codexapp/helper_process_test.go new file mode 100644 index 0000000..839f32b --- /dev/null +++ b/codexapp/helper_process_test.go @@ -0,0 +1,541 @@ +package codexapp_test + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "testing" + "time" +) + +func TestMain(m *testing.M) { + if mode := os.Getenv("GO_WANT_CODEXAPP_HELPER"); mode != "" { + os.Exit(runCodexAppServerHelper(mode)) + } + os.Exit(m.Run()) +} + +func runCodexAppServerHelper(mode string) int { + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return 2 + } + var request struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + Capabilities struct { + ExperimentalAPI bool `json:"experimentalApi"` + } `json:"capabilities"` + ClientInfo struct { + Name string `json:"name"` + Title string `json:"title"` + Version string `json:"version"` + } `json:"clientInfo"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil { + return 3 + } + if request.Method != "initialize" || request.Params.ClientInfo.Name != "test-client" || + request.Params.ClientInfo.Title != "Test Client" || request.Params.ClientInfo.Version != "1.2.3" { + return 4 + } + if mode == "permissions" && !request.Params.Capabilities.ExperimentalAPI { + return 19 + } + response := fmt.Sprintf( + `{"jsonrpc":"2.0","id":%d,"result":{"codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"test","userAgent":"codex-test/1"}}`, + request.ID, + ) + if _, err := fmt.Fprintln(os.Stdout, response); err != nil { + return 5 + } + if !scanner.Scan() { + return 6 + } + var notification struct { + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), ¬ification); err != nil || notification.Method != "initialized" { + return 7 + } + if code := runCodexAppServerMode(mode, scanner); code != 0 { + return code + } + for scanner.Scan() { + } + return 0 +} + +func runCodexAppServerMode(mode string, scanner *bufio.Scanner) int { + switch mode { + case "permissions": + return runPermissionsHelper(scanner) + case "unsupported_permissions": + return runUnsupportedPermissionsHelper(scanner) + case "models": + return runModelsHelper(scanner) + case "telemetry": + return runTelemetryHelper(scanner) + case "cancel": + return runCancelHelper(scanner) + case "supervisor": + return runSupervisorHelper(scanner) + case "crash_turn": + return runCrashTurnHelper(scanner) + case "thread_lifecycle": + return runThreadLifecycleHelper(scanner) + case "rpc_error": + return runRPCErrorHelper(scanner) + case "stderr_exit": + return runStderrExitHelper(scanner) + case "settings": + return runSettingsHelper(scanner) + case "server_request": + return runServerRequestHelper(scanner) + case "turn": + return runTurnHelper(scanner) + default: + return 0 + } +} + +func runPermissionsHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 20 + } + var probe struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &probe); err != nil || probe.Method != "permissionProfile/list" { + return 21 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"data":[],"nextCursor":null}}`+"\n", probe.ID); err != nil { + return 22 + } + if !scanner.Scan() { + return 23 + } + var start struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + Permissions string `json:"permissions"` + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` + Config struct { + Permissions map[string]struct { + Filesystem map[string]string `json:"filesystem"` + Network struct { + Enabled bool `json:"enabled"` + } `json:"network"` + } `json:"permissions"` + } `json:"config"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &start); err != nil || start.Method != "thread/start" { + return 24 + } + profile, ok := start.Params.Config.Permissions[start.Params.Permissions] + if !ok || start.Params.Permissions == "" || profile.Filesystem[":root"] != "deny" || + profile.Filesystem[":minimal"] != "read" || profile.Filesystem[os.Getenv("CODEXAPP_READ_ROOT")] != "read" || + profile.Filesystem[os.Getenv("CODEXAPP_WRITE_ROOT")] != "write" || !profile.Network.Enabled || + len(start.Params.RuntimeWorkspaceRoots) != 1 || start.Params.RuntimeWorkspaceRoots[0] != os.Getenv("CODEXAPP_WRITE_ROOT") { + return 25 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"thread":{"id":"thread-permissions"}}}`+"\n", start.ID); err != nil { + return 26 + } + return 0 +} + +func runUnsupportedPermissionsHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 68 + } + var probe struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &probe); err != nil || probe.Method != "permissionProfile/list" { + return 69 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32601,"message":"method not found"}}`+"\n", probe.ID); err != nil { + return 70 + } + return 0 +} + +func runModelsHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 8 + } + var modelRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + IncludeHidden bool `json:"includeHidden"` + Limit int `json:"limit"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &modelRequest); err != nil || + modelRequest.Method != "model/list" || !modelRequest.Params.IncludeHidden || modelRequest.Params.Limit != 2 { + return 9 + } + models := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":{"data":[{`+ + `"id":"gpt-test","model":"gpt-test","displayName":"GPT Test","description":"test model",`+ + `"defaultReasoningEffort":"high","supportedReasoningEfforts":[{"reasoningEffort":"high","description":"Thorough"}],`+ + `"isDefault":true,"hidden":false,"supportsPersonality":true}],"nextCursor":"next-page"}}`, modelRequest.ID) + if _, err := fmt.Fprintln(os.Stdout, models); err != nil { + return 10 + } + return 0 +} + +func runTelemetryHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 36 + } + var tracedRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Trace struct { + Traceparent string `json:"traceparent"` + } `json:"trace"` + } + if err := json.Unmarshal(scanner.Bytes(), &tracedRequest); err != nil || + tracedRequest.Method != "model/list" || tracedRequest.Trace.Traceparent == "" { + return 37 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"data":[],"nextCursor":null}}`+"\n", tracedRequest.ID); err != nil { + return 38 + } + return 0 +} + +func runCancelHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 39 + } + var request struct { + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil || request.Method != "model/list" { + return 40 + } + return 0 +} + +func runSupervisorHelper(scanner *bufio.Scanner) int { + statePath := os.Getenv("CODEXAPP_SUPERVISOR_STATE") + _, statErr := os.Stat(statePath) + firstGeneration := os.IsNotExist(statErr) + if firstGeneration { + if err := os.WriteFile(statePath, []byte("started"), 0o600); err != nil { + return 41 + } + } + for scanner.Scan() { + var request struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil || request.Method != "model/list" { + return 42 + } + if firstGeneration { + return 43 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"data":[],"nextCursor":null}}`+"\n", request.ID); err != nil { + return 44 + } + } + return 0 +} + +func runCrashTurnHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 45 + } + var threadRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &threadRequest); err != nil || threadRequest.Method != "thread/start" { + return 46 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"thread":{"id":"thread-crash"}}}`+"\n", threadRequest.ID); err != nil { + return 47 + } + if !scanner.Scan() { + return 48 + } + var turnRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &turnRequest); err != nil || turnRequest.Method != "turn/start" { + return 49 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"turn":{"id":"turn-crash","status":"inProgress"}}}`+"\n", turnRequest.ID); err != nil { + return 50 + } + time.Sleep(10 * time.Millisecond) + return 51 +} + +func runThreadLifecycleHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 52 + } + var resume struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + ThreadID string `json:"threadId"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &resume); err != nil || resume.Method != "thread/resume" || resume.Params.ThreadID != "thread-existing" { + return 53 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"model":"gpt-test","thread":{"id":"thread-existing","turns":[]}}}`+"\n", resume.ID); err != nil { + return 54 + } + if !scanner.Scan() { + return 55 + } + var read struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + ThreadID string `json:"threadId"` + IncludeTurns bool `json:"includeTurns"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &read); err != nil || read.Method != "thread/read" || + read.Params.ThreadID != "thread-existing" || !read.Params.IncludeTurns { + return 56 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"thread":{"id":"thread-existing","turns":[{"id":"turn-old","status":"completed"}]}}}`+"\n", read.ID); err != nil { + return 57 + } + if !scanner.Scan() { + return 58 + } + var start struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &start); err != nil || start.Method != "turn/start" { + return 59 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"turn":{"id":"turn-active","status":"inProgress"}}}`+"\n", start.ID); err != nil { + return 60 + } + if !scanner.Scan() { + return 61 + } + var interrupt struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &interrupt); err != nil || interrupt.Method != "turn/interrupt" || + interrupt.Params.ThreadID != "thread-existing" || interrupt.Params.TurnID != "turn-active" { + return 62 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{}}`+"\n", interrupt.ID); err != nil { + return 63 + } + if _, err := fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread-existing","turn":{"id":"turn-active","status":"interrupted"}}}`); err != nil { + return 64 + } + return 0 +} + +func runRPCErrorHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 65 + } + var request struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &request); err != nil || request.Method != "model/list" { + return 66 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32000,"message":"unavailable","data":{"retry":false}}}`+"\n", request.ID); err != nil { + return 67 + } + return 0 +} + +func runStderrExitHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 71 + } + _, _ = fmt.Fprintln(os.Stderr, "app server exploded") + return 72 +} + +func runSettingsHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 73 + } + var startThread struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + ModelProvider string `json:"modelProvider"` + ApprovalPolicy string `json:"approvalPolicy"` + BaseInstructions string `json:"baseInstructions"` + DeveloperInstructions string `json:"developerInstructions"` + Personality string `json:"personality"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &startThread); err != nil || startThread.Method != "thread/start" || + startThread.Params.ModelProvider != "openai" || startThread.Params.ApprovalPolicy != "on-request" || + startThread.Params.BaseInstructions != "base" || startThread.Params.DeveloperInstructions != "developer" || + startThread.Params.Personality != "pragmatic" { + return 74 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"thread":{"id":"thread-settings"}}}`+"\n", startThread.ID); err != nil { + return 75 + } + if !scanner.Scan() { + return 76 + } + var startTurn struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + Model string `json:"model"` + Cwd string `json:"cwd"` + Effort string `json:"effort"` + Summary string `json:"summary"` + Personality string `json:"personality"` + OutputSchema json.RawMessage `json:"outputSchema"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &startTurn); err != nil || startTurn.Method != "turn/start" || + startTurn.Params.Model != "gpt-test" || startTurn.Params.Cwd != "/workspace" || startTurn.Params.Effort != "high" || + startTurn.Params.Summary != "concise" || startTurn.Params.Personality != "friendly" || len(startTurn.Params.OutputSchema) == 0 { + return 77 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"turn":{"id":"turn-settings","status":"inProgress"}}}`+"\n", startTurn.ID); err != nil { + return 78 + } + if _, err := fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread-settings","turn":{"id":"turn-settings","status":"completed"}}}`); err != nil { + return 79 + } + return 0 +} + +func runServerRequestHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 27 + } + var firstModelRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(scanner.Bytes(), &firstModelRequest); err != nil || firstModelRequest.Method != "model/list" { + return 28 + } + if _, err := fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","id":"approval-1","method":"item/commandExecution/requestApproval","trace":{"traceparent":"00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"},"params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","command":"go test ./...","cwd":"/workspace","reason":"run tests","availableDecisions":["acceptForSession","decline"]}}`); err != nil { + return 29 + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"data":[],"nextCursor":null}}`+"\n", firstModelRequest.ID); err != nil { + return 30 + } + var secondModelID int64 + approvalReceived := false + for secondModelID == 0 || !approvalReceived { + if !scanner.Scan() { + return 31 + } + var message struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Result struct { + Decision string `json:"decision"` + } `json:"result"` + } + if err := json.Unmarshal(scanner.Bytes(), &message); err != nil { + return 32 + } + if message.Method == "model/list" { + if err := json.Unmarshal(message.ID, &secondModelID); err != nil { + return 33 + } + } + if string(message.ID) == `"approval-1"` { + if message.Result.Decision != "acceptForSession" { + return 34 + } + approvalReceived = true + } + } + if _, err := fmt.Fprintf(os.Stdout, `{"jsonrpc":"2.0","id":%d,"result":{"data":[],"nextCursor":null}}`+"\n", secondModelID); err != nil { + return 35 + } + return 0 +} + +func runTurnHelper(scanner *bufio.Scanner) int { + if !scanner.Scan() { + return 11 + } + var threadRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + Model string `json:"model"` + Cwd string `json:"cwd"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &threadRequest); err != nil || + threadRequest.Method != "thread/start" || threadRequest.Params.Model != "gpt-test" || threadRequest.Params.Cwd != "/workspace" { + return 12 + } + thread := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":{"model":"gpt-test","thread":{`+ + `"id":"thread-1","preview":"","createdAt":10,"updatedAt":11}}}`, threadRequest.ID) + if _, err := fmt.Fprintln(os.Stdout, thread); err != nil { + return 13 + } + if !scanner.Scan() { + return 14 + } + var turnRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Params struct { + ThreadID string `json:"threadId"` + Input []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"input"` + } `json:"params"` + } + if err := json.Unmarshal(scanner.Bytes(), &turnRequest); err != nil || turnRequest.Method != "turn/start" || + turnRequest.Params.ThreadID != "thread-1" || len(turnRequest.Params.Input) != 1 || + turnRequest.Params.Input[0].Type != "text" || turnRequest.Params.Input[0].Text != "hello" { + return 15 + } + started := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":{"turn":{"id":"turn-1","status":"inProgress"}}}`, turnRequest.ID) + if _, err := fmt.Fprintln(os.Stdout, started); err != nil { + return 16 + } + if _, err := fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"hello"}}`); err != nil { + return 17 + } + if _, err := fmt.Fprintln(os.Stdout, `{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}}`); err != nil { + return 18 + } + return 0 +} diff --git a/codexapp/input_test.go b/codexapp/input_test.go new file mode 100644 index 0000000..70c1f28 --- /dev/null +++ b/codexapp/input_test.go @@ -0,0 +1,35 @@ +package codexapp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInputConstructorsMapToProtocolVariants(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input Input + expected string + }{ + {"text", Text("hello"), `{"type":"text","text":"hello"}`}, + {"image", Image("https://example.test/image.png"), `{"type":"image","url":"https://example.test/image.png"}`}, + {"local image", LocalImage("/tmp/image.png"), `{"type":"localImage","path":"/tmp/image.png"}`}, + {"audio", Audio("https://example.test/audio.wav"), `{"type":"audio","url":"https://example.test/audio.wav"}`}, + {"local audio", LocalAudio("/tmp/audio.wav"), `{"type":"localAudio","path":"/tmp/audio.wav"}`}, + {"skill", Skill("review", "/skills/review/SKILL.md"), `{"type":"skill","name":"review","path":"/skills/review/SKILL.md"}`}, + {"mention", Mention("README", "/workspace/README.md"), `{"type":"mention","name":"README","path":"/workspace/README.md"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + wire, err := mapInput(tt.input) + require.NoError(t, err) + encoded, err := json.Marshal(wire) + require.NoError(t, err) + require.JSONEq(t, tt.expected, string(encoded)) + }) + } +} diff --git a/codexapp/internal/protocol/cmd/generate/main.go b/codexapp/internal/protocol/cmd/generate/main.go new file mode 100644 index 0000000..45a1664 --- /dev/null +++ b/codexapp/internal/protocol/cmd/generate/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "go/format" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +var schemaFiles = []string{ + "v1/InitializeParams.json", + "v1/InitializeResponse.json", + "v2/ModelListParams.json", + "v2/ModelListResponse.json", + "v2/PermissionProfileListParams.json", + "v2/PermissionProfileListResponse.json", + "v2/ThreadStartParams.json", + "v2/ThreadStartResponse.json", + "v2/ThreadResumeParams.json", + "v2/ThreadResumeResponse.json", + "v2/ThreadReadParams.json", + "v2/ThreadReadResponse.json", + "v2/TurnStartParams.json", + "v2/TurnStartResponse.json", + "v2/TurnInterruptParams.json", + "v2/TurnInterruptResponse.json", + "v2/TurnStartedNotification.json", + "v2/TurnCompletedNotification.json", + "v2/ItemStartedNotification.json", + "v2/ItemCompletedNotification.json", + "v2/AgentMessageDeltaNotification.json", + "v2/PlanDeltaNotification.json", + "v2/ReasoningSummaryTextDeltaNotification.json", + "v2/ReasoningSummaryPartAddedNotification.json", + "v2/ReasoningTextDeltaNotification.json", + "v2/CommandExecutionOutputDeltaNotification.json", + "v2/TurnDiffUpdatedNotification.json", + "v2/TurnPlanUpdatedNotification.json", + "v2/ErrorNotification.json", + "v2/WarningNotification.json", + "v2/ThreadTokenUsageUpdatedNotification.json", + "v2/ServerRequestResolvedNotification.json", + "CommandExecutionRequestApprovalParams.json", + "CommandExecutionRequestApprovalResponse.json", + "FileChangeRequestApprovalParams.json", + "FileChangeRequestApprovalResponse.json", + "PermissionsRequestApprovalParams.json", + "PermissionsRequestApprovalResponse.json", + "ToolRequestUserInputParams.json", + "ToolRequestUserInputResponse.json", + "McpServerElicitationRequestParams.json", + "McpServerElicitationRequestResponse.json", +} + +const generatedGoArtifactName = "protocol.gen.go" + +func generatedSchemaName(source string) string { + return strings.TrimSuffix(source, filepath.Ext(source)) + ".gen.json" +} + +func artifactRoots() []string { + return []string{"schema", generatedGoArtifactName, "schema-version.txt", "version.gen.go"} +} + +func main() { + check := flag.Bool("check", false, "compare generated artifacts without modifying the repository") + codex := flag.String("codex", "codex", "Codex CLI executable") + quicktype := flag.String("quicktype", "quicktype", "quicktype executable") + flag.Parse() + + if err := run(*check, *codex, *quicktype); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(check bool, codex, quicktype string) error { + protocolDir, err := protocolDirectory() + if err != nil { + return err + } + temporary, err := os.MkdirTemp("", "codexapp-protocol-*") + if err != nil { + return fmt.Errorf("create temporary directory: %w", err) + } + defer os.RemoveAll(temporary) + + upstream := filepath.Join(temporary, "upstream") + //nolint:gosec // Generated source trees use conventional directory permissions. + if err := os.MkdirAll(upstream, 0o755); err != nil { + return fmt.Errorf("create upstream schema directory: %w", err) + } + if err := command(codex, "app-server", "generate-json-schema", "--experimental", "--out", upstream); err != nil { + return err + } + + artifacts := filepath.Join(temporary, "artifacts") + schemaDir := filepath.Join(artifacts, "schema") + for _, name := range schemaFiles { + if err := copyFile(filepath.Join(upstream, name), filepath.Join(schemaDir, generatedSchemaName(name))); err != nil { + return err + } + } + + generated := filepath.Join(artifacts, generatedGoArtifactName) + arguments := []string{"--lang", "go", "--src-lang", "schema", "--package", "protocol"} + for _, name := range schemaFiles { + arguments = append(arguments, "--src", filepath.Join(schemaDir, generatedSchemaName(name))) + } + arguments = append(arguments, "--out", generated) + if err := command(quicktype, arguments...); err != nil { + return err + } + if err := formatGo(generated); err != nil { + return err + } + + versionOutput, err := exec.Command(codex, "--version").Output() + if err != nil { + return fmt.Errorf("run %s --version: %w", codex, err) + } + version := strings.TrimSpace(string(versionOutput)) + //nolint:gosec // Generated source artifacts use conventional file permissions. + if err := os.WriteFile(filepath.Join(artifacts, "schema-version.txt"), []byte(version+"\n"), 0o644); err != nil { + return fmt.Errorf("write schema version: %w", err) + } + if err := writeVersionGo(filepath.Join(artifacts, "version.gen.go"), version); err != nil { + return err + } + + if check { + return compareArtifacts(artifacts, protocolDir) + } + return installArtifacts(artifacts, protocolDir) +} + +func protocolDirectory() (string, error) { + _, current, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("resolve generator source path") + } + return filepath.Clean(filepath.Join(filepath.Dir(current), "..", "..")), nil +} + +func command(name string, arguments ...string) error { + //nolint:gosec // Executables are explicit generator command-line inputs. + cmd := exec.Command(name, arguments...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("run %s: %w", name, err) + } + return nil +} + +func copyFile(source, target string) error { + //nolint:gosec // Paths are derived from the generator's private temporary tree and repository schema tree. + contents, err := os.ReadFile(source) + if err != nil { + return fmt.Errorf("read %s: %w", source, err) + } + //nolint:gosec // Generated source trees use conventional directory permissions. + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(target), err) + } + //nolint:gosec // Generated source artifacts use conventional file permissions. + if err := os.WriteFile(target, contents, 0o644); err != nil { + return fmt.Errorf("write %s: %w", target, err) + } + return nil +} + +func formatGo(path string) error { + //nolint:gosec // The path is an artifact inside the generator's private temporary tree. + contents, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read generated Go: %w", err) + } + formatted, err := format.Source(contents) + if err != nil { + return fmt.Errorf("format generated Go: %w", err) + } + //nolint:gosec // Generated Go source uses conventional file permissions. + if err := os.WriteFile(path, formatted, 0o644); err != nil { + return fmt.Errorf("write formatted Go: %w", err) + } + return nil +} + +func writeVersionGo(path, version string) error { + source := fmt.Sprintf("// Code generated by codexapp protocol generator. DO NOT EDIT.\n\npackage protocol\n\nconst CodexVersion = %q\n", version) + formatted, err := format.Source([]byte(source)) + if err != nil { + return fmt.Errorf("format generated version: %w", err) + } + //nolint:gosec // Generated Go source uses conventional file permissions. + if err := os.WriteFile(path, formatted, 0o644); err != nil { + return fmt.Errorf("write generated version: %w", err) + } + return nil +} + +func installArtifacts(source, target string) error { + if err := os.RemoveAll(filepath.Join(target, "schema")); err != nil { + return fmt.Errorf("remove old schema snapshot: %w", err) + } + for _, name := range artifactRoots() { + from := filepath.Join(source, name) + to := filepath.Join(target, name) + info, err := os.Stat(from) + if err != nil { + return err + } + if info.IsDir() { + if err := copyTree(from, to); err != nil { + return err + } + continue + } + if err := copyFile(from, to); err != nil { + return err + } + } + return nil +} + +func compareArtifacts(source, target string) error { + wanted, err := filesUnder(source) + if err != nil { + return err + } + actual := make([]string, 0, len(wanted)) + for _, root := range artifactRoots() { + path := filepath.Join(target, root) + info, statErr := os.Stat(path) + if statErr != nil { + return fmt.Errorf("generated artifacts are stale: %s is missing", path) + } + if info.IsDir() { + files, walkErr := filesUnder(path) + if walkErr != nil { + return walkErr + } + for _, name := range files { + actual = append(actual, filepath.Join(root, name)) + } + } else { + actual = append(actual, root) + } + } + sort.Strings(actual) + if !equalStrings(wanted, actual) { + return errors.New("generated artifacts are stale: file set differs; run mise run codexapp:generate") + } + for _, name := range wanted { + //nolint:gosec // Both paths are confined to the known artifact and repository schema trees. + expected, readErr := os.ReadFile(filepath.Join(source, name)) + if readErr != nil { + return readErr + } + //nolint:gosec // Both paths are confined to the known artifact and repository schema trees. + got, readErr := os.ReadFile(filepath.Join(target, name)) + if readErr != nil { + return readErr + } + if !bytes.Equal(expected, got) { + return fmt.Errorf("generated artifact %s is stale; run mise run codexapp:generate", name) + } + } + return nil +} + +func filesUnder(root string) ([]string, error) { + var files []string + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + files = append(files, relative) + return nil + }) + sort.Strings(files) + return files, err +} + +func copyTree(source, target string) error { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + if entry.IsDir() { + //nolint:gosec // Generated source trees use conventional directory permissions. + return os.MkdirAll(filepath.Join(target, relative), 0o755) + } + return copyFile(path, filepath.Join(target, relative)) + }) +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/codexapp/internal/protocol/cmd/generate/main_test.go b/codexapp/internal/protocol/cmd/generate/main_test.go new file mode 100644 index 0000000..619301b --- /dev/null +++ b/codexapp/internal/protocol/cmd/generate/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGeneratedSchemaNameAddsGeneratedSuffix(t *testing.T) { + t.Parallel() + tests := map[string]string{ + "CommandExecutionRequestApprovalParams.json": "CommandExecutionRequestApprovalParams.gen.json", + "v1/InitializeParams.json": "v1/InitializeParams.gen.json", + "v2/TurnStartResponse.json": "v2/TurnStartResponse.gen.json", + } + + for source, expected := range tests { + t.Run(source, func(t *testing.T) { + t.Parallel() + require.Equal(t, expected, generatedSchemaName(source)) + }) + } +} + +func TestGeneratedGoNameHasGeneratedSuffix(t *testing.T) { + t.Parallel() + require.Equal(t, "protocol.gen.go", generatedGoArtifactName) +} diff --git a/codexapp/internal/protocol/generate.go b/codexapp/internal/protocol/generate.go new file mode 100644 index 0000000..650d7a2 --- /dev/null +++ b/codexapp/internal/protocol/generate.go @@ -0,0 +1,4 @@ +// Package protocol contains generated, version-specific Codex App Server wire types. +package protocol + +//go:generate go run ./cmd/generate diff --git a/codexapp/internal/protocol/protocol.gen.go b/codexapp/internal/protocol/protocol.gen.go new file mode 100644 index 0000000..f291471 --- /dev/null +++ b/codexapp/internal/protocol/protocol.gen.go @@ -0,0 +1,5354 @@ +// Code generated from JSON Schema using quicktype. DO NOT EDIT. +// To parse and unparse this JSON data, add this code to your project and do: +// +// initializeParams, err := UnmarshalInitializeParams(bytes) +// bytes, err = initializeParams.Marshal() +// +// initializeResponse, err := UnmarshalInitializeResponse(bytes) +// bytes, err = initializeResponse.Marshal() +// +// modelListParams, err := UnmarshalModelListParams(bytes) +// bytes, err = modelListParams.Marshal() +// +// modelListResponse, err := UnmarshalModelListResponse(bytes) +// bytes, err = modelListResponse.Marshal() +// +// permissionProfileListParams, err := UnmarshalPermissionProfileListParams(bytes) +// bytes, err = permissionProfileListParams.Marshal() +// +// permissionProfileListResponse, err := UnmarshalPermissionProfileListResponse(bytes) +// bytes, err = permissionProfileListResponse.Marshal() +// +// threadStartParams, err := UnmarshalThreadStartParams(bytes) +// bytes, err = threadStartParams.Marshal() +// +// threadStartResponse, err := UnmarshalThreadStartResponse(bytes) +// bytes, err = threadStartResponse.Marshal() +// +// threadResumeParams, err := UnmarshalThreadResumeParams(bytes) +// bytes, err = threadResumeParams.Marshal() +// +// threadResumeResponse, err := UnmarshalThreadResumeResponse(bytes) +// bytes, err = threadResumeResponse.Marshal() +// +// threadReadParams, err := UnmarshalThreadReadParams(bytes) +// bytes, err = threadReadParams.Marshal() +// +// threadReadResponse, err := UnmarshalThreadReadResponse(bytes) +// bytes, err = threadReadResponse.Marshal() +// +// turnStartParams, err := UnmarshalTurnStartParams(bytes) +// bytes, err = turnStartParams.Marshal() +// +// turnStartResponse, err := UnmarshalTurnStartResponse(bytes) +// bytes, err = turnStartResponse.Marshal() +// +// turnInterruptParams, err := UnmarshalTurnInterruptParams(bytes) +// bytes, err = turnInterruptParams.Marshal() +// +// turnInterruptResponse, err := UnmarshalTurnInterruptResponse(bytes) +// bytes, err = turnInterruptResponse.Marshal() +// +// turnStartedNotification, err := UnmarshalTurnStartedNotification(bytes) +// bytes, err = turnStartedNotification.Marshal() +// +// turnCompletedNotification, err := UnmarshalTurnCompletedNotification(bytes) +// bytes, err = turnCompletedNotification.Marshal() +// +// itemStartedNotification, err := UnmarshalItemStartedNotification(bytes) +// bytes, err = itemStartedNotification.Marshal() +// +// itemCompletedNotification, err := UnmarshalItemCompletedNotification(bytes) +// bytes, err = itemCompletedNotification.Marshal() +// +// agentMessageDeltaNotification, err := UnmarshalAgentMessageDeltaNotification(bytes) +// bytes, err = agentMessageDeltaNotification.Marshal() +// +// planDeltaNotification, err := UnmarshalPlanDeltaNotification(bytes) +// bytes, err = planDeltaNotification.Marshal() +// +// reasoningSummaryTextDeltaNotification, err := UnmarshalReasoningSummaryTextDeltaNotification(bytes) +// bytes, err = reasoningSummaryTextDeltaNotification.Marshal() +// +// reasoningSummaryPartAddedNotification, err := UnmarshalReasoningSummaryPartAddedNotification(bytes) +// bytes, err = reasoningSummaryPartAddedNotification.Marshal() +// +// reasoningTextDeltaNotification, err := UnmarshalReasoningTextDeltaNotification(bytes) +// bytes, err = reasoningTextDeltaNotification.Marshal() +// +// commandExecutionOutputDeltaNotification, err := UnmarshalCommandExecutionOutputDeltaNotification(bytes) +// bytes, err = commandExecutionOutputDeltaNotification.Marshal() +// +// turnDiffUpdatedNotification, err := UnmarshalTurnDiffUpdatedNotification(bytes) +// bytes, err = turnDiffUpdatedNotification.Marshal() +// +// turnPlanUpdatedNotification, err := UnmarshalTurnPlanUpdatedNotification(bytes) +// bytes, err = turnPlanUpdatedNotification.Marshal() +// +// errorNotification, err := UnmarshalErrorNotification(bytes) +// bytes, err = errorNotification.Marshal() +// +// warningNotification, err := UnmarshalWarningNotification(bytes) +// bytes, err = warningNotification.Marshal() +// +// threadTokenUsageUpdatedNotification, err := UnmarshalThreadTokenUsageUpdatedNotification(bytes) +// bytes, err = threadTokenUsageUpdatedNotification.Marshal() +// +// serverRequestResolvedNotification, err := UnmarshalServerRequestResolvedNotification(bytes) +// bytes, err = serverRequestResolvedNotification.Marshal() +// +// commandExecutionRequestApprovalParams, err := UnmarshalCommandExecutionRequestApprovalParams(bytes) +// bytes, err = commandExecutionRequestApprovalParams.Marshal() +// +// commandExecutionRequestApprovalResponse, err := UnmarshalCommandExecutionRequestApprovalResponse(bytes) +// bytes, err = commandExecutionRequestApprovalResponse.Marshal() +// +// fileChangeRequestApprovalParams, err := UnmarshalFileChangeRequestApprovalParams(bytes) +// bytes, err = fileChangeRequestApprovalParams.Marshal() +// +// fileChangeRequestApprovalResponse, err := UnmarshalFileChangeRequestApprovalResponse(bytes) +// bytes, err = fileChangeRequestApprovalResponse.Marshal() +// +// permissionsRequestApprovalParams, err := UnmarshalPermissionsRequestApprovalParams(bytes) +// bytes, err = permissionsRequestApprovalParams.Marshal() +// +// permissionsRequestApprovalResponse, err := UnmarshalPermissionsRequestApprovalResponse(bytes) +// bytes, err = permissionsRequestApprovalResponse.Marshal() +// +// toolRequestUserInputParams, err := UnmarshalToolRequestUserInputParams(bytes) +// bytes, err = toolRequestUserInputParams.Marshal() +// +// toolRequestUserInputResponse, err := UnmarshalToolRequestUserInputResponse(bytes) +// bytes, err = toolRequestUserInputResponse.Marshal() +// +// mCPServerElicitationRequestParams, err := UnmarshalMCPServerElicitationRequestParams(bytes) +// bytes, err = mCPServerElicitationRequestParams.Marshal() +// +// mCPServerElicitationRequestResponse, err := UnmarshalMCPServerElicitationRequestResponse(bytes) +// bytes, err = mCPServerElicitationRequestResponse.Marshal() + +package protocol + +import "bytes" +import "errors" + +import "encoding/json" + +func UnmarshalInitializeParams(data []byte) (InitializeParams, error) { + var r InitializeParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *InitializeParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalInitializeResponse(data []byte) (InitializeResponse, error) { + var r InitializeResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *InitializeResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalModelListParams(data []byte) (ModelListParams, error) { + var r ModelListParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ModelListParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalModelListResponse(data []byte) (ModelListResponse, error) { + var r ModelListResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ModelListResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalPermissionProfileListParams(data []byte) (PermissionProfileListParams, error) { + var r PermissionProfileListParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *PermissionProfileListParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalPermissionProfileListResponse(data []byte) (PermissionProfileListResponse, error) { + var r PermissionProfileListResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *PermissionProfileListResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalThreadStartParams(data []byte) (ThreadStartParams, error) { + var r ThreadStartParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ThreadStartParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalThreadStartResponse(data []byte) (ThreadStartResponse, error) { + var r ThreadStartResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ThreadStartResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalThreadResumeParams(data []byte) (ThreadResumeParams, error) { + var r ThreadResumeParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ThreadResumeParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalThreadResumeResponse(data []byte) (ThreadResumeResponse, error) { + var r ThreadResumeResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ThreadResumeResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalThreadReadParams(data []byte) (ThreadReadParams, error) { + var r ThreadReadParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ThreadReadParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalThreadReadResponse(data []byte) (ThreadReadResponse, error) { + var r ThreadReadResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ThreadReadResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalTurnStartParams(data []byte) (TurnStartParams, error) { + var r TurnStartParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnStartParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalTurnStartResponse(data []byte) (TurnStartResponse, error) { + var r TurnStartResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnStartResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalTurnInterruptParams(data []byte) (TurnInterruptParams, error) { + var r TurnInterruptParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnInterruptParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +type TurnInterruptResponse map[string]interface{} + +func UnmarshalTurnInterruptResponse(data []byte) (TurnInterruptResponse, error) { + var r TurnInterruptResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnInterruptResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalTurnStartedNotification(data []byte) (TurnStartedNotification, error) { + var r TurnStartedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnStartedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalTurnCompletedNotification(data []byte) (TurnCompletedNotification, error) { + var r TurnCompletedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnCompletedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalItemStartedNotification(data []byte) (ItemStartedNotification, error) { + var r ItemStartedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ItemStartedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalItemCompletedNotification(data []byte) (ItemCompletedNotification, error) { + var r ItemCompletedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ItemCompletedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalAgentMessageDeltaNotification(data []byte) (AgentMessageDeltaNotification, error) { + var r AgentMessageDeltaNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *AgentMessageDeltaNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalPlanDeltaNotification(data []byte) (PlanDeltaNotification, error) { + var r PlanDeltaNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *PlanDeltaNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalReasoningSummaryTextDeltaNotification(data []byte) (ReasoningSummaryTextDeltaNotification, error) { + var r ReasoningSummaryTextDeltaNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ReasoningSummaryTextDeltaNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalReasoningSummaryPartAddedNotification(data []byte) (ReasoningSummaryPartAddedNotification, error) { + var r ReasoningSummaryPartAddedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ReasoningSummaryPartAddedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalReasoningTextDeltaNotification(data []byte) (ReasoningTextDeltaNotification, error) { + var r ReasoningTextDeltaNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ReasoningTextDeltaNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalCommandExecutionOutputDeltaNotification(data []byte) (CommandExecutionOutputDeltaNotification, error) { + var r CommandExecutionOutputDeltaNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *CommandExecutionOutputDeltaNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalTurnDiffUpdatedNotification(data []byte) (TurnDiffUpdatedNotification, error) { + var r TurnDiffUpdatedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnDiffUpdatedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalTurnPlanUpdatedNotification(data []byte) (TurnPlanUpdatedNotification, error) { + var r TurnPlanUpdatedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *TurnPlanUpdatedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalErrorNotification(data []byte) (ErrorNotification, error) { + var r ErrorNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ErrorNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalWarningNotification(data []byte) (WarningNotification, error) { + var r WarningNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *WarningNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalThreadTokenUsageUpdatedNotification(data []byte) (ThreadTokenUsageUpdatedNotification, error) { + var r ThreadTokenUsageUpdatedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ThreadTokenUsageUpdatedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalServerRequestResolvedNotification(data []byte) (ServerRequestResolvedNotification, error) { + var r ServerRequestResolvedNotification + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ServerRequestResolvedNotification) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalCommandExecutionRequestApprovalParams(data []byte) (CommandExecutionRequestApprovalParams, error) { + var r CommandExecutionRequestApprovalParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *CommandExecutionRequestApprovalParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalCommandExecutionRequestApprovalResponse(data []byte) (CommandExecutionRequestApprovalResponse, error) { + var r CommandExecutionRequestApprovalResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *CommandExecutionRequestApprovalResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalFileChangeRequestApprovalParams(data []byte) (FileChangeRequestApprovalParams, error) { + var r FileChangeRequestApprovalParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *FileChangeRequestApprovalParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalFileChangeRequestApprovalResponse(data []byte) (FileChangeRequestApprovalResponse, error) { + var r FileChangeRequestApprovalResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *FileChangeRequestApprovalResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalPermissionsRequestApprovalParams(data []byte) (PermissionsRequestApprovalParams, error) { + var r PermissionsRequestApprovalParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *PermissionsRequestApprovalParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalPermissionsRequestApprovalResponse(data []byte) (PermissionsRequestApprovalResponse, error) { + var r PermissionsRequestApprovalResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *PermissionsRequestApprovalResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalToolRequestUserInputParams(data []byte) (ToolRequestUserInputParams, error) { + var r ToolRequestUserInputParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ToolRequestUserInputParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalToolRequestUserInputResponse(data []byte) (ToolRequestUserInputResponse, error) { + var r ToolRequestUserInputResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *ToolRequestUserInputResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalMCPServerElicitationRequestParams(data []byte) (MCPServerElicitationRequestParams, error) { + var r MCPServerElicitationRequestParams + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *MCPServerElicitationRequestParams) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +func UnmarshalMCPServerElicitationRequestResponse(data []byte) (MCPServerElicitationRequestResponse, error) { + var r MCPServerElicitationRequestResponse + err := json.Unmarshal(data, &r) + return r, err +} + +func (r *MCPServerElicitationRequestResponse) Marshal() ([]byte, error) { + return json.Marshal(r) +} + +type InitializeParams struct { + Capabilities *InitializeCapabilities `json:"capabilities"` + ClientInfo ClientInfo `json:"clientInfo"` +} + +// Client-declared capabilities negotiated during initialize. +type InitializeCapabilities struct { + // Opt into receiving experimental API methods and fields. + ExperimentalAPI *bool `json:"experimentalApi,omitempty"` + // Allow downstream MCP servers to request OpenAI extended form elicitations. + MCPServerOpenaiFormElicitation *bool `json:"mcpServerOpenaiFormElicitation,omitempty"` + // Exact notification method names that should be suppressed for this connection (for + // example `thread/started`). + OptOutNotificationMethods []string `json:"optOutNotificationMethods"` + // Opt into `attestation/generate` requests for upstream `x-oai-attestation`. + RequestAttestation *bool `json:"requestAttestation,omitempty"` +} + +type ClientInfo struct { + Name string `json:"name"` + Title *string `json:"title"` + Version string `json:"version"` +} + +type InitializeResponse struct { + // Absolute path to the server's $CODEX_HOME directory. + CodexHome string `json:"codexHome"` + // Platform family for the running app-server target, for example `"unix"` or `"windows"`. + PlatformFamily string `json:"platformFamily"` + // Operating system for the running app-server target, for example `"macos"`, `"linux"`, or + // `"windows"`. + PlatformOS string `json:"platformOs"` + UserAgent string `json:"userAgent"` +} + +type ModelListParams struct { + // Opaque pagination cursor returned by a previous call. + Cursor *string `json:"cursor"` + // When true, include models that are hidden from the default picker list. + IncludeHidden *bool `json:"includeHidden"` + // Optional page size; defaults to a reasonable server-side value. + Limit *int64 `json:"limit"` +} + +type ModelListResponse struct { + Data []Model `json:"data"` + // Opaque cursor to pass to the next call to continue after the last item. If None, there + // are no more items to return. + NextCursor *string `json:"nextCursor"` +} + +type Model struct { + // Deprecated: use `serviceTiers` instead. + AdditionalSpeedTiers []string `json:"additionalSpeedTiers,omitempty"` + AvailabilityNux *ModelAvailabilityNux `json:"availabilityNux"` + DefaultReasoningEffort string `json:"defaultReasoningEffort"` + // Catalog default service tier id for this model, when one is configured. + DefaultServiceTier *string `json:"defaultServiceTier"` + Description string `json:"description"` + DisplayName string `json:"displayName"` + Hidden bool `json:"hidden"` + ID string `json:"id"` + InputModalities []InputModality `json:"inputModalities,omitempty"` + IsDefault bool `json:"isDefault"` + Model string `json:"model"` + ServiceTiers []ModelServiceTier `json:"serviceTiers,omitempty"` + SupportedReasoningEfforts []ReasoningEffortOption `json:"supportedReasoningEfforts"` + SupportsPersonality *bool `json:"supportsPersonality,omitempty"` + Upgrade *string `json:"upgrade"` + UpgradeInfo *ModelUpgradeInfo `json:"upgradeInfo"` +} + +type ModelAvailabilityNux struct { + Message string `json:"message"` +} + +type ModelServiceTier struct { + Description string `json:"description"` + ID string `json:"id"` + Name string `json:"name"` +} + +type ReasoningEffortOption struct { + Description string `json:"description"` + ReasoningEffort string `json:"reasoningEffort"` +} + +type ModelUpgradeInfo struct { + MigrationMarkdown *string `json:"migrationMarkdown"` + Model string `json:"model"` + ModelLink *string `json:"modelLink"` + UpgradeCopy *string `json:"upgradeCopy"` +} + +type PermissionProfileListParams struct { + // Opaque pagination cursor returned by a previous call. + Cursor *string `json:"cursor"` + // Optional working directory to resolve project config layers. + Cwd *string `json:"cwd"` + // Optional page size; defaults to the full result set. + Limit *int64 `json:"limit"` +} + +type PermissionProfileListResponse struct { + Data []PermissionProfileSummary `json:"data"` + // Opaque cursor to pass to the next call to continue after the last item. If None, there + // are no more items to return. + NextCursor *string `json:"nextCursor"` +} + +type PermissionProfileSummary struct { + // Whether the effective requirements allow selecting this profile. + Allowed bool `json:"allowed"` + // Optional user-facing description for display in clients. + Description *string `json:"description"` + // Available permission profile identifier. + ID string `json:"id"` +} + +type ThreadStartParams struct { + // Allow a provider with an authoritative static model catalog to replace an unavailable + // requested model with its default. + AllowProviderModelFallback *bool `json:"allowProviderModelFallback,omitempty"` + ApprovalPolicy *ThreadStartParamsApprovalPolicy `json:"approvalPolicy"` + // Override where approval requests are routed for review on this thread and subsequent + // turns. + ApprovalsReviewer *ApprovalsReviewer `json:"approvalsReviewer"` + BaseInstructions *string `json:"baseInstructions"` + Config map[string]interface{} `json:"config"` + Cwd *string `json:"cwd"` + DeveloperInstructions *string `json:"developerInstructions"` + DynamicTools []DynamicToolSpec `json:"dynamicTools"` + // Optional sticky environments for this thread. + // + // Omitted selects the default environment when environment access is enabled. Empty + // disables environment access for turns that do not provide a turn override. Non-empty + // selects the first environment as the current turn environment. + Environments []ThreadStartParamsEnvironment `json:"environments"` + Ephemeral *bool `json:"ephemeral"` + // If true, opt into emitting raw Responses API items on the event stream. This is for + // internal use only (e.g. Codex Cloud). + ExperimentalRawEvents *bool `json:"experimentalRawEvents,omitempty"` + // Persisted thread history contract to use for this new thread. + HistoryMode *ThreadHistoryMode `json:"historyMode"` + // Test-only experimental field used to validate experimental gating and schema filtering + // behavior in a stable way. + MockExperimentalField *string `json:"mockExperimentalField"` + Model *string `json:"model"` + ModelProvider *string `json:"modelProvider"` + // @deprecated Ignored. Use Ultra reasoning effort for proactive multi-agent behavior. + MultiAgentMode *ThreadStartParamsMultiAgentMode `json:"multiAgentMode"` + // Named profile id for this thread. Cannot be combined with `sandbox`. + Permissions *string `json:"permissions"` + Personality *Personality `json:"personality"` + // Replace the thread's runtime workspace roots. Paths must be absolute. + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` + Sandbox *SandboxMode `json:"sandbox"` + // Capability roots selected for this thread by the hosting platform. + SelectedCapabilityRoots []SelectedCapabilityRoot `json:"selectedCapabilityRoots"` + ServiceName *string `json:"serviceName"` + ServiceTier *string `json:"serviceTier"` + SessionStartSource *ThreadStartSource `json:"sessionStartSource"` + // Optional client-supplied analytics source classification for this thread. + ThreadSource *string `json:"threadSource"` +} + +type PurpleGranularAskForApproval struct { + Granular PurpleGranular `json:"granular"` +} + +type PurpleGranular struct { + MCPElicitations bool `json:"mcp_elicitations"` + RequestPermissions *bool `json:"request_permissions,omitempty"` + Rules bool `json:"rules"` + SandboxApproval bool `json:"sandbox_approval"` + SkillApproval *bool `json:"skill_approval,omitempty"` +} + +type DynamicToolSpec struct { + DeferLoading *bool `json:"deferLoading,omitempty"` + Description string `json:"description"` + InputSchema interface{} `json:"inputSchema"` + Name string `json:"name"` + Type DynamicToolSpecType `json:"type"` + Tools []DynamicToolNamespaceTool `json:"tools,omitempty"` +} + +type DynamicToolNamespaceTool struct { + DeferLoading *bool `json:"deferLoading,omitempty"` + Description string `json:"description"` + InputSchema interface{} `json:"inputSchema"` + Name string `json:"name"` + Type FunctionDynamicToolNamespaceToolType `json:"type"` +} + +type ThreadStartParamsEnvironment struct { + Cwd string `json:"cwd"` + EnvironmentID string `json:"environmentId"` + // Environment-native runtime workspace roots. Omitted defaults to `cwd`. + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` +} + +type PurpleCustomMultiAgentMode struct { + Custom string `json:"custom"` +} + +// A user-selected root that can expose one or more runtime capabilities. +type SelectedCapabilityRoot struct { + // Stable identifier supplied by the capability selection platform. + ID string `json:"id"` + // Where the selected root can be resolved. + Location CapabilityRootLocation `json:"location"` +} + +// Where the selected root can be resolved. +// +// Location used to resolve a selected capability root. +// +// A path owned by an execution environment. +type CapabilityRootLocation struct { + EnvironmentID string `json:"environmentId"` + // Absolute path for the root in the selected environment. + Path string `json:"path"` + Type EnvironmentCapabilityRootLocationType `json:"type"` +} + +type ThreadStartResponse struct { + // Named or implicit built-in profile that produced the active permissions, when known. + ActivePermissionProfile *ThreadStartResponseActivePermissionProfile `json:"activePermissionProfile"` + ApprovalPolicy *ThreadStartResponseAskForApproval `json:"approvalPolicy"` + // Reviewer currently used for approval requests on this thread. + ApprovalsReviewer ApprovalsReviewer `json:"approvalsReviewer"` + Cwd string `json:"cwd"` + // Environment-native paths to instruction source files currently loaded for this thread. + InstructionSources []string `json:"instructionSources,omitempty"` + Model string `json:"model"` + ModelProvider string `json:"modelProvider"` + // @deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior. + MultiAgentMode *ThreadStartResponseMultiAgentMode `json:"multiAgentMode"` + ReasoningEffort *string `json:"reasoningEffort"` + // Thread-scoped runtime workspace roots used to materialize `:workspace_roots`. + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots,omitempty"` + // Legacy sandbox policy retained for compatibility. Experimental clients should prefer + // `activePermissionProfile` for profile provenance. + Sandbox ThreadStartResponseSandboxPolicy `json:"sandbox"` + ServiceTier *string `json:"serviceTier"` + Thread ThreadStartResponseThread `json:"thread"` +} + +type ThreadStartResponseActivePermissionProfile struct { + // Parent profile identifier from the selected permissions profile's `extends` setting, when + // present. + Extends *string `json:"extends"` + // Identifier from `default_permissions` or the implicit built-in default, such as + // `:workspace` or a user-defined `[permissions.]` profile. + ID string `json:"id"` +} + +type FluffyGranularAskForApproval struct { + Granular FluffyGranular `json:"granular"` +} + +type FluffyGranular struct { + MCPElicitations bool `json:"mcp_elicitations"` + RequestPermissions *bool `json:"request_permissions,omitempty"` + Rules bool `json:"rules"` + SandboxApproval bool `json:"sandbox_approval"` + SkillApproval *bool `json:"skill_approval,omitempty"` +} + +type FluffyCustomMultiAgentMode struct { + Custom string `json:"custom"` +} + +// Legacy sandbox policy retained for compatibility. Experimental clients should prefer +// `activePermissionProfile` for profile provenance. +type ThreadStartResponseSandboxPolicy struct { + Type SandboxPolicyType `json:"type"` + NetworkAccess *NetworkAccessUnion `json:"networkAccess"` + ExcludeSlashTmp *bool `json:"excludeSlashTmp,omitempty"` + ExcludeTmpdirEnvVar *bool `json:"excludeTmpdirEnvVar,omitempty"` + WritableRoots []string `json:"writableRoots,omitempty"` +} + +type ThreadStartResponseThread struct { + // Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + AgentNickname *string `json:"agentNickname"` + // Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + AgentRole *string `json:"agentRole"` + // Whether the app server accepts direct turn input for this loaded thread. `None` means the + // capability is unavailable, such as for an unloaded stored thread. + CanAcceptDirectInput *bool `json:"canAcceptDirectInput"` + // Version of the CLI that created the thread. + CLIVersion string `json:"cliVersion"` + // Unix timestamp (in seconds) when the thread was created. + CreatedAt int64 `json:"createdAt"` + // Working directory captured for the thread. + Cwd string `json:"cwd"` + // Whether the thread is ephemeral and should not be materialized on disk. + Ephemeral bool `json:"ephemeral"` + // Optional implementation-specific thread data. + Extra map[string]interface{} `json:"extra"` + // Source thread id when this thread was created by forking another thread. + ForkedFromID *string `json:"forkedFromId"` + // Optional Git metadata captured when the thread was created. + GitInfo *PurpleGitInfo `json:"gitInfo"` + // Persisted thread history contract selected when this thread was created. + HistoryMode *ThreadHistoryMode `json:"historyMode,omitempty"` + // Identifier for this thread. Codex-generated thread IDs are UUIDv7. + ID string `json:"id"` + // Whether the thread has been pinned by the user. + IsPinned *bool `json:"isPinned,omitempty"` + // Model provider used for this thread (for example, 'openai'). + ModelProvider string `json:"modelProvider"` + // Optional user-facing thread title. + Name *string `json:"name"` + // The ID of the parent thread. This will only be set if this thread is a subagent. + ParentThreadID *string `json:"parentThreadId"` + // [UNSTABLE] Path to the thread on disk. + Path *string `json:"path"` + // Usually the first user message in the thread, if available. + Preview string `json:"preview"` + // Unix timestamp (in seconds) used for thread recency ordering. + RecencyAt *int64 `json:"recencyAt"` + // Session id shared by threads that belong to the same session tree. + SessionID string `json:"sessionId"` + // Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + Source *StickySessionSource `json:"source"` + // Current runtime status for the thread. + Status PurpleThreadStatus `json:"status"` + // Optional analytics source classification for this thread. + ThreadSource *string `json:"threadSource"` + // Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` + // (when `includeTurns` is true) responses. For all other responses and notifications + // returning a Thread, the turns field will be an empty list. + Turns []PurpleTurn `json:"turns"` + // Unix timestamp (in seconds) when the thread was last updated. + UpdatedAt int64 `json:"updatedAt"` +} + +type PurpleGitInfo struct { + Branch *string `json:"branch"` + OriginURL *string `json:"originUrl"` + SHA *string `json:"sha"` +} + +type PurpleSessionSource struct { + Custom *string `json:"custom,omitempty"` + SubAgent *StickySubAgentSource `json:"subAgent"` +} + +type PurpleSubAgentSource struct { + ThreadSpawn *PurpleThreadSpawn `json:"thread_spawn,omitempty"` + Other *string `json:"other,omitempty"` +} + +type PurpleThreadSpawn struct { + AgentNickname *string `json:"agent_nickname"` + AgentPath *string `json:"agent_path"` + AgentRole *string `json:"agent_role"` + Depth int64 `json:"depth"` + ParentThreadID string `json:"parent_thread_id"` +} + +// Current runtime status for the thread. +type PurpleThreadStatus struct { + Type ThreadStatusType `json:"type"` + ActiveFlags []ThreadActiveFlag `json:"activeFlags,omitempty"` +} + +type PurpleTurn struct { + // Unix timestamp (in seconds) when the turn completed. + CompletedAt *int64 `json:"completedAt"` + // Duration between turn start and completion in milliseconds, if known. + DurationMS *int64 `json:"durationMs"` + // Only populated when the Turn's status is failed. + Error *PurpleTurnError `json:"error"` + // Identifier for this turn. Codex-generated turn IDs are UUIDv7. + ID string `json:"id"` + // Thread items currently included in this turn payload. + Items []PurpleThreadItem `json:"items"` + // Describes how much of `items` has been loaded for this turn. + ItemsView *TurnItemsView `json:"itemsView,omitempty"` + // Unix timestamp (in seconds) when the turn started. + StartedAt *int64 `json:"startedAt"` + Status TurnStatus `json:"status"` +} + +type PurpleTurnError struct { + AdditionalDetails *string `json:"additionalDetails"` + CodexErrorInfo *AmbitiousCodexErrorInfo `json:"codexErrorInfo"` + Message string `json:"message"` +} + +// Failed to connect to the response SSE stream. +// +// The response SSE stream disconnected in the middle of a turn before completion. +// +// Reached the retry limit for responses. +// +// Returned when `turn/start` or `turn/steer` is submitted while the current active turn +// cannot accept same-turn steering, for example `/review` or manual `/compact`. +type PurpleCodexErrorInfo struct { + HTTPConnectionFailed *PurpleHTTPConnectionFailed `json:"httpConnectionFailed,omitempty"` + ResponseStreamConnectionFailed *PurpleResponseStreamConnectionFailed `json:"responseStreamConnectionFailed,omitempty"` + ResponseStreamDisconnected *PurpleResponseStreamDisconnected `json:"responseStreamDisconnected,omitempty"` + ResponseTooManyFailedAttempts *PurpleResponseTooManyFailedAttempts `json:"responseTooManyFailedAttempts,omitempty"` + ActiveTurnNotSteerable *PurpleActiveTurnNotSteerable `json:"activeTurnNotSteerable,omitempty"` +} + +type PurpleActiveTurnNotSteerable struct { + TurnKind NonSteerableTurnKind `json:"turnKind"` +} + +type PurpleHTTPConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type PurpleResponseStreamConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type PurpleResponseStreamDisconnected struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type PurpleResponseTooManyFailedAttempts struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type PurpleThreadItem struct { + ClientID *string `json:"clientId"` + Content []CunningUserInput `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []PurpleHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *PurpleMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []PurpleCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []PurpleFileUpdateChange `json:"changes,omitempty"` + AppContext *PurpleMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *PurpleMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *PurpleResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []PurpleDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]PurpleCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *PurpleWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type PurpleWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type PurpleCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type PurpleMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type PurpleFileUpdateChange struct { + Diff string `json:"diff"` + Kind PurplePatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type PurplePatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type PurpleCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type PurpleUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []PurpleTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type PurpleTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange PurpleByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type PurpleByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type PurpleDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type PurpleMCPToolCallError struct { + Message string `json:"message"` +} + +type PurpleHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type PurpleMemoryCitation struct { + Entries []PurpleMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type PurpleMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type PurpleMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +// There are three ways to resume a thread: 1. By thread_id: load the thread from disk by +// thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. +// 3. By path: load the thread from disk by path and resume it. +// +// For non-running threads, the precedence is: history > non-empty path > thread_id. If +// using history or a non-empty path for a non-running thread, the thread_id param will be +// ignored. +// +// If thread_id identifies a running thread, app-server rejoins that thread and treats a +// non-empty path as a consistency check against the active rollout path. Empty string path +// values are treated as absent. +// +// Prefer using thread_id whenever possible. +type ThreadResumeParams struct { + ApprovalPolicy *ThreadResumeParamsApprovalPolicy `json:"approvalPolicy"` + // Override where approval requests are routed for review on this thread and subsequent + // turns. + ApprovalsReviewer *ApprovalsReviewer `json:"approvalsReviewer"` + BaseInstructions *string `json:"baseInstructions"` + Config map[string]interface{} `json:"config"` + Cwd *string `json:"cwd"` + DeveloperInstructions *string `json:"developerInstructions"` + // When true, return only thread metadata and live-resume state without populating + // `thread.turns`. This is useful when the client plans to call `thread/turns/list` + // immediately after resuming. + ExcludeTurns *bool `json:"excludeTurns,omitempty"` + // [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. If specified, the thread will be resumed with + // the provided history instead of loaded from disk. + History []ResponseItem `json:"history"` + // When present, include a `thread/turns/list` page in the resume response so clients can + // bootstrap recent turns without a second request. + InitialTurnsPage *ThreadResumeInitialTurnsPageParams `json:"initialTurnsPage"` + // Configuration overrides for the resumed thread, if any. + Model *string `json:"model"` + ModelProvider *string `json:"modelProvider"` + // [UNSTABLE] Specify the rollout path to resume from. If specified for a non-running + // thread, the thread_id param will be ignored. If thread_id identifies a running thread, + // the path must match the active rollout path. + Path *string `json:"path"` + // Named profile id for the resumed thread. Cannot be combined with `sandbox`. + Permissions *string `json:"permissions"` + Personality *Personality `json:"personality"` + // Replace the thread's runtime workspace roots. Paths must be absolute. + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` + Sandbox *SandboxMode `json:"sandbox"` + ServiceTier *string `json:"serviceTier"` + ThreadID string `json:"threadId"` +} + +type TentacledGranularAskForApproval struct { + Granular TentacledGranular `json:"granular"` +} + +type TentacledGranular struct { + MCPElicitations bool `json:"mcp_elicitations"` + RequestPermissions *bool `json:"request_permissions,omitempty"` + Rules bool `json:"rules"` + SandboxApproval bool `json:"sandbox_approval"` + SkillApproval *bool `json:"skill_approval,omitempty"` +} + +type ResponseItem struct { + Content []ContentItem `json:"content"` + // Legacy id field retained for compatibility with older payloads. + ID *string `json:"id"` + InternalChatMessageMetadataPassthrough *InternalChatMessageMetadataPassthrough `json:"internal_chat_message_metadata_passthrough"` + Phase *MessagePhase `json:"phase"` + Role *string `json:"role,omitempty"` + Type ResponseItemType `json:"type"` + Author *string `json:"author,omitempty"` + Recipient *string `json:"recipient,omitempty"` + EncryptedContent *string `json:"encrypted_content"` + Summary []ReasoningItemReasoningSummary `json:"summary,omitempty"` + Action *Action `json:"action"` + // Set when using the Responses API. + CallID *string `json:"call_id"` + Status *string `json:"status"` + Arguments interface{} `json:"arguments"` + Name *string `json:"name"` + Namespace *string `json:"namespace"` + Execution *string `json:"execution,omitempty"` + Output *FunctionCallOutputBody `json:"output"` + Input *string `json:"input,omitempty"` + Tools []interface{} `json:"tools,omitempty"` + Result *string `json:"result,omitempty"` + RevisedPrompt *string `json:"revised_prompt"` +} + +type Action struct { + Command []string `json:"command,omitempty"` + Env map[string]string `json:"env"` + TimeoutMS *int64 `json:"timeout_ms"` + Type ActionType `json:"type"` + User *string `json:"user"` + WorkingDirectory *string `json:"working_directory"` + Queries []string `json:"queries"` + Query *string `json:"query"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type ContentItem struct { + Text *string `json:"text,omitempty"` + Type Type `json:"type"` + Detail *ImageDetail `json:"detail"` + ImageURL *string `json:"image_url,omitempty"` + AudioURL *string `json:"audio_url,omitempty"` + EncryptedContent *string `json:"encrypted_content,omitempty"` +} + +// Internal Responses API passthrough metadata copied into underlying chat messages. +// +// Responses API strongly types this payload. Do not modify it without first getting API +// approval and making the corresponding Responses API change. +type InternalChatMessageMetadataPassthrough struct { + TurnID *string `json:"turn_id"` +} + +// Responses API compatible content items that can be returned by a tool call. This is a +// subset of ContentItem with the types we support as function call outputs. +type FunctionCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type FunctionCallOutputContentItemType `json:"type"` + Detail *ImageDetail `json:"detail"` + ImageURL *string `json:"image_url,omitempty"` + AudioURL *string `json:"audio_url,omitempty"` + EncryptedContent *string `json:"encrypted_content,omitempty"` +} + +type ReasoningItemReasoningSummary struct { + Text string `json:"text"` + Type SummaryTextReasoningItemReasoningSummaryType `json:"type"` +} + +type ThreadResumeInitialTurnsPageParams struct { + // How much item detail to include for each returned turn; defaults to summary. + ItemsView *TurnItemsView `json:"itemsView"` + // Optional turn page size. + Limit *int64 `json:"limit"` + // Optional turn pagination direction; defaults to descending. + SortDirection *SortDirection `json:"sortDirection"` +} + +type ThreadResumeResponse struct { + // Named or implicit built-in profile that produced the active permissions, when known. + ActivePermissionProfile *ThreadResumeResponseActivePermissionProfile `json:"activePermissionProfile"` + ApprovalPolicy *ThreadResumeResponseAskForApproval `json:"approvalPolicy"` + // Reviewer currently used for approval requests on this thread. + ApprovalsReviewer ApprovalsReviewer `json:"approvalsReviewer"` + Cwd string `json:"cwd"` + // `thread/turns/list` page returned when requested by `initialTurnsPage`. + InitialTurnsPage *TurnsPage `json:"initialTurnsPage"` + // Environment-native paths to instruction source files currently loaded for this thread. + InstructionSources []string `json:"instructionSources,omitempty"` + // Opaque head cursor for hydrating paginated items backwards. + // + // Pass this as `cursor` to `thread/items/list` with `sortDirection: "desc"`. The first page + // includes the cursor's head item. + ItemsBackwardsCursor *string `json:"itemsBackwardsCursor"` + Model string `json:"model"` + ModelProvider string `json:"modelProvider"` + // @deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior. + MultiAgentMode *ThreadResumeResponseMultiAgentMode `json:"multiAgentMode"` + ReasoningEffort *string `json:"reasoningEffort"` + // Thread-scoped runtime workspace roots used to materialize `:workspace_roots`. + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots,omitempty"` + // Legacy sandbox policy retained for compatibility. Experimental clients should prefer + // `activePermissionProfile` for profile provenance. + Sandbox ThreadResumeResponseSandboxPolicy `json:"sandbox"` + ServiceTier *string `json:"serviceTier"` + Thread ThreadResumeResponseThread `json:"thread"` + // Opaque head cursor for hydrating paginated turns backwards. + // + // Pass this as `cursor` to `thread/turns/list` with `sortDirection: "desc"`. The first page + // includes the cursor's head turn. + TurnsBackwardsCursor *string `json:"turnsBackwardsCursor"` +} + +type ThreadResumeResponseActivePermissionProfile struct { + // Parent profile identifier from the selected permissions profile's `extends` setting, when + // present. + Extends *string `json:"extends"` + // Identifier from `default_permissions` or the implicit built-in default, such as + // `:workspace` or a user-defined `[permissions.]` profile. + ID string `json:"id"` +} + +type StickyGranularAskForApproval struct { + Granular StickyGranular `json:"granular"` +} + +type StickyGranular struct { + MCPElicitations bool `json:"mcp_elicitations"` + RequestPermissions *bool `json:"request_permissions,omitempty"` + Rules bool `json:"rules"` + SandboxApproval bool `json:"sandbox_approval"` + SkillApproval *bool `json:"skill_approval,omitempty"` +} + +type TurnsPage struct { + BackwardsCursor *string `json:"backwardsCursor"` + Data []DatumElement `json:"data"` + NextCursor *string `json:"nextCursor"` +} + +type DatumElement struct { + // Unix timestamp (in seconds) when the turn completed. + CompletedAt *int64 `json:"completedAt"` + // Duration between turn start and completion in milliseconds, if known. + DurationMS *int64 `json:"durationMs"` + // Only populated when the Turn's status is failed. + Error *DatumTurnError `json:"error"` + // Identifier for this turn. Codex-generated turn IDs are UUIDv7. + ID string `json:"id"` + // Thread items currently included in this turn payload. + Items []DatumThreadItem `json:"items"` + // Describes how much of `items` has been loaded for this turn. + ItemsView *TurnItemsView `json:"itemsView,omitempty"` + // Unix timestamp (in seconds) when the turn started. + StartedAt *int64 `json:"startedAt"` + Status TurnStatus `json:"status"` +} + +type DatumTurnError struct { + AdditionalDetails *string `json:"additionalDetails"` + CodexErrorInfo *CunningCodexErrorInfo `json:"codexErrorInfo"` + Message string `json:"message"` +} + +// Failed to connect to the response SSE stream. +// +// The response SSE stream disconnected in the middle of a turn before completion. +// +// Reached the retry limit for responses. +// +// Returned when `turn/start` or `turn/steer` is submitted while the current active turn +// cannot accept same-turn steering, for example `/review` or manual `/compact`. +type FluffyCodexErrorInfo struct { + HTTPConnectionFailed *FluffyHTTPConnectionFailed `json:"httpConnectionFailed,omitempty"` + ResponseStreamConnectionFailed *FluffyResponseStreamConnectionFailed `json:"responseStreamConnectionFailed,omitempty"` + ResponseStreamDisconnected *FluffyResponseStreamDisconnected `json:"responseStreamDisconnected,omitempty"` + ResponseTooManyFailedAttempts *FluffyResponseTooManyFailedAttempts `json:"responseTooManyFailedAttempts,omitempty"` + ActiveTurnNotSteerable *FluffyActiveTurnNotSteerable `json:"activeTurnNotSteerable,omitempty"` +} + +type FluffyActiveTurnNotSteerable struct { + TurnKind NonSteerableTurnKind `json:"turnKind"` +} + +type FluffyHTTPConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type FluffyResponseStreamConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type FluffyResponseStreamDisconnected struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type FluffyResponseTooManyFailedAttempts struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type DatumThreadItem struct { + ClientID *string `json:"clientId"` + Content []MagentaUserInput `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []FluffyHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *FluffyMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []FluffyCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []FluffyFileUpdateChange `json:"changes,omitempty"` + AppContext *FluffyMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *FluffyMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *FluffyResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []FluffyDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]FluffyCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *FluffyWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type FluffyWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type FluffyCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type FluffyMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type FluffyFileUpdateChange struct { + Diff string `json:"diff"` + Kind FluffyPatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type FluffyPatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type FluffyCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type FluffyUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []FluffyTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type FluffyTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange FluffyByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type FluffyByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type FluffyDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type FluffyMCPToolCallError struct { + Message string `json:"message"` +} + +type FluffyHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type FluffyMemoryCitation struct { + Entries []FluffyMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type FluffyMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type FluffyMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +type TentacledCustomMultiAgentMode struct { + Custom string `json:"custom"` +} + +// Legacy sandbox policy retained for compatibility. Experimental clients should prefer +// `activePermissionProfile` for profile provenance. +type ThreadResumeResponseSandboxPolicy struct { + Type SandboxPolicyType `json:"type"` + NetworkAccess *NetworkAccessUnion `json:"networkAccess"` + ExcludeSlashTmp *bool `json:"excludeSlashTmp,omitempty"` + ExcludeTmpdirEnvVar *bool `json:"excludeTmpdirEnvVar,omitempty"` + WritableRoots []string `json:"writableRoots,omitempty"` +} + +type ThreadResumeResponseThread struct { + // Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + AgentNickname *string `json:"agentNickname"` + // Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + AgentRole *string `json:"agentRole"` + // Whether the app server accepts direct turn input for this loaded thread. `None` means the + // capability is unavailable, such as for an unloaded stored thread. + CanAcceptDirectInput *bool `json:"canAcceptDirectInput"` + // Version of the CLI that created the thread. + CLIVersion string `json:"cliVersion"` + // Unix timestamp (in seconds) when the thread was created. + CreatedAt int64 `json:"createdAt"` + // Working directory captured for the thread. + Cwd string `json:"cwd"` + // Whether the thread is ephemeral and should not be materialized on disk. + Ephemeral bool `json:"ephemeral"` + // Optional implementation-specific thread data. + Extra map[string]interface{} `json:"extra"` + // Source thread id when this thread was created by forking another thread. + ForkedFromID *string `json:"forkedFromId"` + // Optional Git metadata captured when the thread was created. + GitInfo *FluffyGitInfo `json:"gitInfo"` + // Persisted thread history contract selected when this thread was created. + HistoryMode *ThreadHistoryMode `json:"historyMode,omitempty"` + // Identifier for this thread. Codex-generated thread IDs are UUIDv7. + ID string `json:"id"` + // Whether the thread has been pinned by the user. + IsPinned *bool `json:"isPinned,omitempty"` + // Model provider used for this thread (for example, 'openai'). + ModelProvider string `json:"modelProvider"` + // Optional user-facing thread title. + Name *string `json:"name"` + // The ID of the parent thread. This will only be set if this thread is a subagent. + ParentThreadID *string `json:"parentThreadId"` + // [UNSTABLE] Path to the thread on disk. + Path *string `json:"path"` + // Usually the first user message in the thread, if available. + Preview string `json:"preview"` + // Unix timestamp (in seconds) used for thread recency ordering. + RecencyAt *int64 `json:"recencyAt"` + // Session id shared by threads that belong to the same session tree. + SessionID string `json:"sessionId"` + // Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + Source *IndigoSessionSource `json:"source"` + // Current runtime status for the thread. + Status FluffyThreadStatus `json:"status"` + // Optional analytics source classification for this thread. + ThreadSource *string `json:"threadSource"` + // Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` + // (when `includeTurns` is true) responses. For all other responses and notifications + // returning a Thread, the turns field will be an empty list. + Turns []DatumElement `json:"turns"` + // Unix timestamp (in seconds) when the thread was last updated. + UpdatedAt int64 `json:"updatedAt"` +} + +type FluffyGitInfo struct { + Branch *string `json:"branch"` + OriginURL *string `json:"originUrl"` + SHA *string `json:"sha"` +} + +type FluffySessionSource struct { + Custom *string `json:"custom,omitempty"` + SubAgent *IndigoSubAgentSource `json:"subAgent"` +} + +type FluffySubAgentSource struct { + ThreadSpawn *FluffyThreadSpawn `json:"thread_spawn,omitempty"` + Other *string `json:"other,omitempty"` +} + +type FluffyThreadSpawn struct { + AgentNickname *string `json:"agent_nickname"` + AgentPath *string `json:"agent_path"` + AgentRole *string `json:"agent_role"` + Depth int64 `json:"depth"` + ParentThreadID string `json:"parent_thread_id"` +} + +// Current runtime status for the thread. +type FluffyThreadStatus struct { + Type ThreadStatusType `json:"type"` + ActiveFlags []ThreadActiveFlag `json:"activeFlags,omitempty"` +} + +type ThreadReadParams struct { + // When true, include turns and their items from rollout history. + IncludeTurns *bool `json:"includeTurns,omitempty"` + ThreadID string `json:"threadId"` +} + +type ThreadReadResponse struct { + Thread ThreadReadResponseThread `json:"thread"` +} + +type ThreadReadResponseThread struct { + // Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + AgentNickname *string `json:"agentNickname"` + // Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + AgentRole *string `json:"agentRole"` + // Whether the app server accepts direct turn input for this loaded thread. `None` means the + // capability is unavailable, such as for an unloaded stored thread. + CanAcceptDirectInput *bool `json:"canAcceptDirectInput"` + // Version of the CLI that created the thread. + CLIVersion string `json:"cliVersion"` + // Unix timestamp (in seconds) when the thread was created. + CreatedAt int64 `json:"createdAt"` + // Working directory captured for the thread. + Cwd string `json:"cwd"` + // Whether the thread is ephemeral and should not be materialized on disk. + Ephemeral bool `json:"ephemeral"` + // Optional implementation-specific thread data. + Extra map[string]interface{} `json:"extra"` + // Source thread id when this thread was created by forking another thread. + ForkedFromID *string `json:"forkedFromId"` + // Optional Git metadata captured when the thread was created. + GitInfo *TentacledGitInfo `json:"gitInfo"` + // Persisted thread history contract selected when this thread was created. + HistoryMode *ThreadHistoryMode `json:"historyMode,omitempty"` + // Identifier for this thread. Codex-generated thread IDs are UUIDv7. + ID string `json:"id"` + // Whether the thread has been pinned by the user. + IsPinned *bool `json:"isPinned,omitempty"` + // Model provider used for this thread (for example, 'openai'). + ModelProvider string `json:"modelProvider"` + // Optional user-facing thread title. + Name *string `json:"name"` + // The ID of the parent thread. This will only be set if this thread is a subagent. + ParentThreadID *string `json:"parentThreadId"` + // [UNSTABLE] Path to the thread on disk. + Path *string `json:"path"` + // Usually the first user message in the thread, if available. + Preview string `json:"preview"` + // Unix timestamp (in seconds) used for thread recency ordering. + RecencyAt *int64 `json:"recencyAt"` + // Session id shared by threads that belong to the same session tree. + SessionID string `json:"sessionId"` + // Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + Source *IndecentSessionSource `json:"source"` + // Current runtime status for the thread. + Status TentacledThreadStatus `json:"status"` + // Optional analytics source classification for this thread. + ThreadSource *string `json:"threadSource"` + // Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` + // (when `includeTurns` is true) responses. For all other responses and notifications + // returning a Thread, the turns field will be an empty list. + Turns []FluffyTurn `json:"turns"` + // Unix timestamp (in seconds) when the thread was last updated. + UpdatedAt int64 `json:"updatedAt"` +} + +type TentacledGitInfo struct { + Branch *string `json:"branch"` + OriginURL *string `json:"originUrl"` + SHA *string `json:"sha"` +} + +type TentacledSessionSource struct { + Custom *string `json:"custom,omitempty"` + SubAgent *IndecentSubAgentSource `json:"subAgent"` +} + +type TentacledSubAgentSource struct { + ThreadSpawn *TentacledThreadSpawn `json:"thread_spawn,omitempty"` + Other *string `json:"other,omitempty"` +} + +type TentacledThreadSpawn struct { + AgentNickname *string `json:"agent_nickname"` + AgentPath *string `json:"agent_path"` + AgentRole *string `json:"agent_role"` + Depth int64 `json:"depth"` + ParentThreadID string `json:"parent_thread_id"` +} + +// Current runtime status for the thread. +type TentacledThreadStatus struct { + Type ThreadStatusType `json:"type"` + ActiveFlags []ThreadActiveFlag `json:"activeFlags,omitempty"` +} + +type FluffyTurn struct { + // Unix timestamp (in seconds) when the turn completed. + CompletedAt *int64 `json:"completedAt"` + // Duration between turn start and completion in milliseconds, if known. + DurationMS *int64 `json:"durationMs"` + // Only populated when the Turn's status is failed. + Error *FluffyTurnError `json:"error"` + // Identifier for this turn. Codex-generated turn IDs are UUIDv7. + ID string `json:"id"` + // Thread items currently included in this turn payload. + Items []FluffyThreadItem `json:"items"` + // Describes how much of `items` has been loaded for this turn. + ItemsView *TurnItemsView `json:"itemsView,omitempty"` + // Unix timestamp (in seconds) when the turn started. + StartedAt *int64 `json:"startedAt"` + Status TurnStatus `json:"status"` +} + +type FluffyTurnError struct { + AdditionalDetails *string `json:"additionalDetails"` + CodexErrorInfo *MagentaCodexErrorInfo `json:"codexErrorInfo"` + Message string `json:"message"` +} + +// Failed to connect to the response SSE stream. +// +// The response SSE stream disconnected in the middle of a turn before completion. +// +// Reached the retry limit for responses. +// +// Returned when `turn/start` or `turn/steer` is submitted while the current active turn +// cannot accept same-turn steering, for example `/review` or manual `/compact`. +type TentacledCodexErrorInfo struct { + HTTPConnectionFailed *TentacledHTTPConnectionFailed `json:"httpConnectionFailed,omitempty"` + ResponseStreamConnectionFailed *TentacledResponseStreamConnectionFailed `json:"responseStreamConnectionFailed,omitempty"` + ResponseStreamDisconnected *TentacledResponseStreamDisconnected `json:"responseStreamDisconnected,omitempty"` + ResponseTooManyFailedAttempts *TentacledResponseTooManyFailedAttempts `json:"responseTooManyFailedAttempts,omitempty"` + ActiveTurnNotSteerable *TentacledActiveTurnNotSteerable `json:"activeTurnNotSteerable,omitempty"` +} + +type TentacledActiveTurnNotSteerable struct { + TurnKind NonSteerableTurnKind `json:"turnKind"` +} + +type TentacledHTTPConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type TentacledResponseStreamConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type TentacledResponseStreamDisconnected struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type TentacledResponseTooManyFailedAttempts struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type FluffyThreadItem struct { + ClientID *string `json:"clientId"` + Content []FriskyUserInput `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []TentacledHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *TentacledMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []TentacledCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []TentacledFileUpdateChange `json:"changes,omitempty"` + AppContext *TentacledMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *TentacledMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *TentacledResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []TentacledDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]TentacledCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *TentacledWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type TentacledWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type TentacledCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type TentacledMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type TentacledFileUpdateChange struct { + Diff string `json:"diff"` + Kind TentacledPatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type TentacledPatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type TentacledCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type TentacledUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []TentacledTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type TentacledTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange TentacledByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type TentacledByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type TentacledDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type TentacledMCPToolCallError struct { + Message string `json:"message"` +} + +type TentacledHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type TentacledMemoryCitation struct { + Entries []TentacledMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type TentacledMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type TentacledMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +type TurnStartParams struct { + // Optional client-provided context fragments keyed by an opaque source identifier. + AdditionalContext map[string]AdditionalContextEntry `json:"additionalContext"` + // Override the approval policy for this turn and subsequent turns. + ApprovalPolicy *TurnStartParamsApprovalPolicy `json:"approvalPolicy"` + // Override where approval requests are routed for review on this turn and subsequent turns. + ApprovalsReviewer *ApprovalsReviewer `json:"approvalsReviewer"` + ClientUserMessageID *string `json:"clientUserMessageId"` + // EXPERIMENTAL - Set a pre-set collaboration mode. Takes precedence over model, + // reasoning_effort, and developer instructions if set. + // + // For `collaboration_mode.settings.developer_instructions`, `null` means "use the built-in + // instructions for the selected mode". + CollaborationMode *CollaborationMode `json:"collaborationMode"` + // Override the working directory for this turn and subsequent turns. + Cwd *string `json:"cwd"` + // Override the reasoning effort for this turn and subsequent turns. + Effort *string `json:"effort"` + // Optional environments for this turn and subsequent turns. + // + // Omitted uses the thread sticky environments. Empty disables environment access for this + // turn. Non-empty selects the first environment as the current turn environment for this + // turn. + Environments []TurnStartParamsEnvironment `json:"environments"` + Input []UserInput `json:"input"` + // Override the model for this turn and subsequent turns. + Model *string `json:"model"` + // @deprecated Ignored. Use `effort: "ultra"` for proactive multi-agent behavior. + MultiAgentMode *TurnStartParamsMultiAgentMode `json:"multiAgentMode"` + // Optional JSON Schema used to constrain the final assistant message for this turn. + OutputSchema interface{} `json:"outputSchema"` + // Select a named permissions profile id for this turn and subsequent turns. Cannot be + // combined with `sandboxPolicy`. + Permissions *string `json:"permissions"` + // Override the personality for this turn and subsequent turns. + Personality *Personality `json:"personality"` + // Optional metadata to enrich Codex's ResponsesAPI turn metadata. + // + // Entries are flattened into the JSON string sent as + // `client_metadata["x-codex-turn-metadata"]` on ResponsesAPI HTTP and websocket requests. + // + // They are not sent as top-level ResponsesAPI `client_metadata` keys, and reserved keys + // such as `session_id`, `thread_id`, `turn_id`, and `window_id` cannot be overridden. + ResponsesapiClientMetadata map[string]string `json:"responsesapiClientMetadata"` + // Replace the thread's runtime workspace roots for this turn and subsequent turns. Paths + // must be absolute. + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` + // Override the sandbox policy for this turn and subsequent turns. + SandboxPolicy *SandboxPolicy `json:"sandboxPolicy"` + // Override the service tier for this turn and subsequent turns. + ServiceTier *string `json:"serviceTier"` + // Override the reasoning summary for this turn and subsequent turns. + Summary *ReasoningSummary `json:"summary"` + ThreadID string `json:"threadId"` +} + +type AdditionalContextEntry struct { + Kind AdditionalContextKind `json:"kind"` + Value string `json:"value"` +} + +type IndigoGranularAskForApproval struct { + Granular IndigoGranular `json:"granular"` +} + +type IndigoGranular struct { + MCPElicitations bool `json:"mcp_elicitations"` + RequestPermissions *bool `json:"request_permissions,omitempty"` + Rules bool `json:"rules"` + SandboxApproval bool `json:"sandbox_approval"` + SkillApproval *bool `json:"skill_approval,omitempty"` +} + +// Collaboration mode for a Codex session. +type CollaborationMode struct { + Mode ModeKind `json:"mode"` + Settings Settings `json:"settings"` +} + +// Settings for a collaboration mode. +type Settings struct { + DeveloperInstructions *string `json:"developer_instructions"` + Model string `json:"model"` + ReasoningEffort *string `json:"reasoning_effort"` +} + +type TurnStartParamsEnvironment struct { + Cwd string `json:"cwd"` + EnvironmentID string `json:"environmentId"` + // Environment-native runtime workspace roots. Omitted defaults to `cwd`. + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots"` +} + +type UserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []UserInputTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type UserInputTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange StickyByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type StickyByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type StickyCustomMultiAgentMode struct { + Custom string `json:"custom"` +} + +type SandboxPolicy struct { + Type SandboxPolicyType `json:"type"` + NetworkAccess *NetworkAccessUnion `json:"networkAccess"` + ExcludeSlashTmp *bool `json:"excludeSlashTmp,omitempty"` + ExcludeTmpdirEnvVar *bool `json:"excludeTmpdirEnvVar,omitempty"` + WritableRoots []string `json:"writableRoots,omitempty"` +} + +type TurnStartResponse struct { + Turn TurnStartResponseTurn `json:"turn"` +} + +type TurnStartResponseTurn struct { + // Unix timestamp (in seconds) when the turn completed. + CompletedAt *int64 `json:"completedAt"` + // Duration between turn start and completion in milliseconds, if known. + DurationMS *int64 `json:"durationMs"` + // Only populated when the Turn's status is failed. + Error *TentacledTurnError `json:"error"` + // Identifier for this turn. Codex-generated turn IDs are UUIDv7. + ID string `json:"id"` + // Thread items currently included in this turn payload. + Items []TentacledThreadItem `json:"items"` + // Describes how much of `items` has been loaded for this turn. + ItemsView *TurnItemsView `json:"itemsView,omitempty"` + // Unix timestamp (in seconds) when the turn started. + StartedAt *int64 `json:"startedAt"` + Status TurnStatus `json:"status"` +} + +type TentacledTurnError struct { + AdditionalDetails *string `json:"additionalDetails"` + CodexErrorInfo *FriskyCodexErrorInfo `json:"codexErrorInfo"` + Message string `json:"message"` +} + +// Failed to connect to the response SSE stream. +// +// The response SSE stream disconnected in the middle of a turn before completion. +// +// Reached the retry limit for responses. +// +// Returned when `turn/start` or `turn/steer` is submitted while the current active turn +// cannot accept same-turn steering, for example `/review` or manual `/compact`. +type StickyCodexErrorInfo struct { + HTTPConnectionFailed *StickyHTTPConnectionFailed `json:"httpConnectionFailed,omitempty"` + ResponseStreamConnectionFailed *StickyResponseStreamConnectionFailed `json:"responseStreamConnectionFailed,omitempty"` + ResponseStreamDisconnected *StickyResponseStreamDisconnected `json:"responseStreamDisconnected,omitempty"` + ResponseTooManyFailedAttempts *StickyResponseTooManyFailedAttempts `json:"responseTooManyFailedAttempts,omitempty"` + ActiveTurnNotSteerable *StickyActiveTurnNotSteerable `json:"activeTurnNotSteerable,omitempty"` +} + +type StickyActiveTurnNotSteerable struct { + TurnKind NonSteerableTurnKind `json:"turnKind"` +} + +type StickyHTTPConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type StickyResponseStreamConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type StickyResponseStreamDisconnected struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type StickyResponseTooManyFailedAttempts struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type TentacledThreadItem struct { + ClientID *string `json:"clientId"` + Content []MischievousUserInput `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []StickyHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *StickyMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []StickyCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []StickyFileUpdateChange `json:"changes,omitempty"` + AppContext *StickyMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *StickyMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *StickyResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []StickyDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]StickyCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *StickyWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type StickyWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type StickyCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type StickyMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type StickyFileUpdateChange struct { + Diff string `json:"diff"` + Kind StickyPatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type StickyPatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type StickyCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type StickyUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []StickyTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type StickyTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange IndigoByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type IndigoByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type StickyDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type StickyMCPToolCallError struct { + Message string `json:"message"` +} + +type StickyHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type StickyMemoryCitation struct { + Entries []StickyMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type StickyMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type StickyMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +type TurnInterruptParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type TurnStartedNotification struct { + ThreadID string `json:"threadId"` + Turn TurnStartedNotificationTurn `json:"turn"` +} + +type TurnStartedNotificationTurn struct { + // Unix timestamp (in seconds) when the turn completed. + CompletedAt *int64 `json:"completedAt"` + // Duration between turn start and completion in milliseconds, if known. + DurationMS *int64 `json:"durationMs"` + // Only populated when the Turn's status is failed. + Error *StickyTurnError `json:"error"` + // Identifier for this turn. Codex-generated turn IDs are UUIDv7. + ID string `json:"id"` + // Thread items currently included in this turn payload. + Items []StickyThreadItem `json:"items"` + // Describes how much of `items` has been loaded for this turn. + ItemsView *TurnItemsView `json:"itemsView,omitempty"` + // Unix timestamp (in seconds) when the turn started. + StartedAt *int64 `json:"startedAt"` + Status TurnStatus `json:"status"` +} + +type StickyTurnError struct { + AdditionalDetails *string `json:"additionalDetails"` + CodexErrorInfo *MischievousCodexErrorInfo `json:"codexErrorInfo"` + Message string `json:"message"` +} + +// Failed to connect to the response SSE stream. +// +// The response SSE stream disconnected in the middle of a turn before completion. +// +// Reached the retry limit for responses. +// +// Returned when `turn/start` or `turn/steer` is submitted while the current active turn +// cannot accept same-turn steering, for example `/review` or manual `/compact`. +type IndigoCodexErrorInfo struct { + HTTPConnectionFailed *IndigoHTTPConnectionFailed `json:"httpConnectionFailed,omitempty"` + ResponseStreamConnectionFailed *IndigoResponseStreamConnectionFailed `json:"responseStreamConnectionFailed,omitempty"` + ResponseStreamDisconnected *IndigoResponseStreamDisconnected `json:"responseStreamDisconnected,omitempty"` + ResponseTooManyFailedAttempts *IndigoResponseTooManyFailedAttempts `json:"responseTooManyFailedAttempts,omitempty"` + ActiveTurnNotSteerable *IndigoActiveTurnNotSteerable `json:"activeTurnNotSteerable,omitempty"` +} + +type IndigoActiveTurnNotSteerable struct { + TurnKind NonSteerableTurnKind `json:"turnKind"` +} + +type IndigoHTTPConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type IndigoResponseStreamConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type IndigoResponseStreamDisconnected struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type IndigoResponseTooManyFailedAttempts struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type StickyThreadItem struct { + ClientID *string `json:"clientId"` + Content []BraggadociousUserInput `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []IndigoHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *IndigoMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []IndigoCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []IndigoFileUpdateChange `json:"changes,omitempty"` + AppContext *IndigoMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *IndigoMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *IndigoResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []IndigoDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]IndigoCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *IndigoWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type IndigoWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type IndigoCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type IndigoMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type IndigoFileUpdateChange struct { + Diff string `json:"diff"` + Kind IndigoPatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type IndigoPatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type IndigoCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type IndigoUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []IndigoTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type IndigoTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange IndecentByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type IndecentByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type IndigoDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type IndigoMCPToolCallError struct { + Message string `json:"message"` +} + +type IndigoHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type IndigoMemoryCitation struct { + Entries []IndigoMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type IndigoMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type IndigoMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +type TurnCompletedNotification struct { + ThreadID string `json:"threadId"` + Turn TurnCompletedNotificationTurn `json:"turn"` +} + +type TurnCompletedNotificationTurn struct { + // Unix timestamp (in seconds) when the turn completed. + CompletedAt *int64 `json:"completedAt"` + // Duration between turn start and completion in milliseconds, if known. + DurationMS *int64 `json:"durationMs"` + // Only populated when the Turn's status is failed. + Error *IndigoTurnError `json:"error"` + // Identifier for this turn. Codex-generated turn IDs are UUIDv7. + ID string `json:"id"` + // Thread items currently included in this turn payload. + Items []IndigoThreadItem `json:"items"` + // Describes how much of `items` has been loaded for this turn. + ItemsView *TurnItemsView `json:"itemsView,omitempty"` + // Unix timestamp (in seconds) when the turn started. + StartedAt *int64 `json:"startedAt"` + Status TurnStatus `json:"status"` +} + +type IndigoTurnError struct { + AdditionalDetails *string `json:"additionalDetails"` + CodexErrorInfo *BraggadociousCodexErrorInfo `json:"codexErrorInfo"` + Message string `json:"message"` +} + +// Failed to connect to the response SSE stream. +// +// The response SSE stream disconnected in the middle of a turn before completion. +// +// Reached the retry limit for responses. +// +// Returned when `turn/start` or `turn/steer` is submitted while the current active turn +// cannot accept same-turn steering, for example `/review` or manual `/compact`. +type IndecentCodexErrorInfo struct { + HTTPConnectionFailed *IndecentHTTPConnectionFailed `json:"httpConnectionFailed,omitempty"` + ResponseStreamConnectionFailed *IndecentResponseStreamConnectionFailed `json:"responseStreamConnectionFailed,omitempty"` + ResponseStreamDisconnected *IndecentResponseStreamDisconnected `json:"responseStreamDisconnected,omitempty"` + ResponseTooManyFailedAttempts *IndecentResponseTooManyFailedAttempts `json:"responseTooManyFailedAttempts,omitempty"` + ActiveTurnNotSteerable *IndecentActiveTurnNotSteerable `json:"activeTurnNotSteerable,omitempty"` +} + +type IndecentActiveTurnNotSteerable struct { + TurnKind NonSteerableTurnKind `json:"turnKind"` +} + +type IndecentHTTPConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type IndecentResponseStreamConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type IndecentResponseStreamDisconnected struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type IndecentResponseTooManyFailedAttempts struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type IndigoThreadItem struct { + ClientID *string `json:"clientId"` + Content []UserInput1 `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []IndecentHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *IndecentMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []IndecentCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []IndecentFileUpdateChange `json:"changes,omitempty"` + AppContext *IndecentMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *IndecentMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *IndecentResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []IndecentDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]IndecentCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *IndecentWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type IndecentWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type IndecentCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type IndecentMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type IndecentFileUpdateChange struct { + Diff string `json:"diff"` + Kind IndecentPatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type IndecentPatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type IndecentCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type IndecentUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []IndecentTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type IndecentTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange HilariousByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type HilariousByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type IndecentDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type IndecentMCPToolCallError struct { + Message string `json:"message"` +} + +type IndecentHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type IndecentMemoryCitation struct { + Entries []IndecentMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type IndecentMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type IndecentMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +type ItemStartedNotification struct { + Item ItemStartedNotificationThreadItem `json:"item"` + // Unix timestamp (in milliseconds) when this item lifecycle started. + StartedAtMS int64 `json:"startedAtMs"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type ItemStartedNotificationThreadItem struct { + ClientID *string `json:"clientId"` + Content []UserInput2 `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []HilariousHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *HilariousMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []HilariousCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []HilariousFileUpdateChange `json:"changes,omitempty"` + AppContext *HilariousMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *HilariousMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *HilariousResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []HilariousDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]HilariousCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *HilariousWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type HilariousWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type HilariousCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type HilariousMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type HilariousFileUpdateChange struct { + Diff string `json:"diff"` + Kind HilariousPatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type HilariousPatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type HilariousCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type HilariousUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []HilariousTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type HilariousTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange AmbitiousByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type AmbitiousByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type HilariousDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type HilariousMCPToolCallError struct { + Message string `json:"message"` +} + +type HilariousHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type HilariousMemoryCitation struct { + Entries []HilariousMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type HilariousMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type HilariousMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +type ItemCompletedNotification struct { + // Unix timestamp (in milliseconds) when this item lifecycle completed. + CompletedAtMS int64 `json:"completedAtMs"` + Item ItemCompletedNotificationThreadItem `json:"item"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +// EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and +// may not match the concatenation of `PlanDelta` text. +// +// Display item emitted by the interruptible `clock.sleep` tool. +type ItemCompletedNotificationThreadItem struct { + ClientID *string `json:"clientId"` + Content []UserInput3 `json:"content,omitempty"` + // Unique identifier for this collab tool call. + ID string `json:"id"` + Type ThreadItemType `json:"type"` + Fragments []AmbitiousHookPromptFragment `json:"fragments,omitempty"` + MemoryCitation *AmbitiousMemoryCitation `json:"memoryCitation"` + Phase *MessagePhase `json:"phase"` + Text *string `json:"text,omitempty"` + Summary []string `json:"summary,omitempty"` + // The command's output, aggregated from stdout and stderr. + AggregatedOutput *string `json:"aggregatedOutput"` + // The command to be executed. + Command *string `json:"command,omitempty"` + // A best-effort parsing of the command to understand the action(s) it will perform. This + // returns a list of CommandAction objects because a single shell command may be composed of + // many commands piped together. + CommandActions []AmbitiousCommandAction `json:"commandActions,omitempty"` + // The command's working directory. + Cwd *string `json:"cwd,omitempty"` + // The duration of the command execution in milliseconds. + // + // The duration of the MCP tool call in milliseconds. + // + // The duration of the dynamic tool call in milliseconds. + DurationMS *int64 `json:"durationMs"` + // The command's exit code. + ExitCode *int64 `json:"exitCode"` + // Trusted first-party plugin id when this command resolves to one plugin script. + PluginID *string `json:"pluginId"` + // Identifier for the underlying PTY process (when available). + ProcessID *string `json:"processId"` + // Safe plugin-relative path when this command resolves to one plugin script. + ScriptPath *string `json:"scriptPath"` + Source *CommandExecutionSource `json:"source,omitempty"` + // Current status of the collab tool call. + Status *string `json:"status,omitempty"` + Changes []AmbitiousFileUpdateChange `json:"changes,omitempty"` + AppContext *AmbitiousMCPToolCallAppContext `json:"appContext"` + Arguments interface{} `json:"arguments"` + Error *AmbitiousMCPToolCallError `json:"error"` + // Deprecated: use `appContext.resourceUri` instead. + MCPAppResourceURI *string `json:"mcpAppResourceUri"` + Result *AmbitiousResult `json:"result"` + Server *string `json:"server,omitempty"` + // Name of the collab tool that was invoked. + Tool *string `json:"tool,omitempty"` + ContentItems []AmbitiousDynamicToolCallOutputContentItem `json:"contentItems"` + Namespace *string `json:"namespace"` + Success *bool `json:"success"` + // Last known status of the target agents, when available. + AgentsStates map[string]AmbitiousCollabAgentState `json:"agentsStates,omitempty"` + // Model requested for the spawned agent, when applicable. + Model *string `json:"model"` + // Prompt text sent as part of the collab tool call, when available. + Prompt *string `json:"prompt"` + // Reasoning effort requested for the spawned agent, when applicable. + ReasoningEffort *string `json:"reasoningEffort"` + // Thread ID of the receiving agent, when applicable. In case of spawn operation, this + // corresponds to the newly spawned agent. + ReceiverThreadIDS []string `json:"receiverThreadIds,omitempty"` + // Thread ID of the agent issuing the collab request. + SenderThreadID *string `json:"senderThreadId,omitempty"` + AgentPath *string `json:"agentPath,omitempty"` + AgentThreadID *string `json:"agentThreadId,omitempty"` + Kind *SubAgentActivityKind `json:"kind,omitempty"` + Action *AmbitiousWebSearchAction `json:"action"` + Query *string `json:"query,omitempty"` + // Structured search results returned out-of-band by standalone web search. + // + // These stay as opaque JSON at the extension/app-server boundary so new result fields and + // result types can pass through without a Codex release. + Results []interface{} `json:"results"` + Path *string `json:"path,omitempty"` + RevisedPrompt *string `json:"revisedPrompt"` + SavedPath *string `json:"savedPath"` + Review *string `json:"review,omitempty"` +} + +type AmbitiousWebSearchAction struct { + Queries []string `json:"queries"` + Query *string `json:"query"` + Type WebSearchActionType `json:"type"` + URL *string `json:"url"` + Pattern *string `json:"pattern"` +} + +type AmbitiousCollabAgentState struct { + Message *string `json:"message"` + Status CollabAgentStatus `json:"status"` +} + +type AmbitiousMCPToolCallAppContext struct { + ActionName *string `json:"actionName"` + AppName *string `json:"appName"` + ConnectorID string `json:"connectorId"` + LinkID *string `json:"linkId"` + ResourceURI *string `json:"resourceUri"` +} + +type AmbitiousFileUpdateChange struct { + Diff string `json:"diff"` + Kind AmbitiousPatchChangeKind `json:"kind"` + Path string `json:"path"` +} + +type AmbitiousPatchChangeKind struct { + Type PatchChangeKindType `json:"type"` + MovePath *string `json:"move_path"` +} + +type AmbitiousCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type AmbitiousUserInput struct { + Text *string `json:"text,omitempty"` + // UI-defined spans within `text` used to render or persist special elements. + TextElements []AmbitiousTextElement `json:"text_elements,omitempty"` + Type UserInputType `json:"type"` + Detail *ImageDetail `json:"detail"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +type AmbitiousTextElement struct { + // Byte range in the parent `text` buffer that this element occupies. + ByteRange CunningByteRange `json:"byteRange"` + // Optional human-readable placeholder for the element, displayed in the UI. + Placeholder *string `json:"placeholder"` +} + +// Byte range in the parent `text` buffer that this element occupies. +type CunningByteRange struct { + End int64 `json:"end"` + Start int64 `json:"start"` +} + +type AmbitiousDynamicToolCallOutputContentItem struct { + Text *string `json:"text,omitempty"` + Type InputDynamicToolCallOutputContentItemType `json:"type"` + ImageURL *string `json:"imageUrl,omitempty"` + AudioURL *string `json:"audioUrl,omitempty"` +} + +type AmbitiousMCPToolCallError struct { + Message string `json:"message"` +} + +type AmbitiousHookPromptFragment struct { + HookRunID string `json:"hookRunId"` + Text string `json:"text"` +} + +type AmbitiousMemoryCitation struct { + Entries []AmbitiousMemoryCitationEntry `json:"entries"` + ThreadIDS []string `json:"threadIds"` +} + +type AmbitiousMemoryCitationEntry struct { + LineEnd int64 `json:"lineEnd"` + LineStart int64 `json:"lineStart"` + Note string `json:"note"` + Path string `json:"path"` +} + +type AmbitiousMCPToolCallResult struct { + Meta interface{} `json:"_meta"` + Content []interface{} `json:"content"` + StructuredContent interface{} `json:"structuredContent"` +} + +type AgentMessageDeltaNotification struct { + Delta string `json:"delta"` + ItemID string `json:"itemId"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume +// concatenated deltas match the completed plan item content. +type PlanDeltaNotification struct { + Delta string `json:"delta"` + ItemID string `json:"itemId"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type ReasoningSummaryTextDeltaNotification struct { + Delta string `json:"delta"` + ItemID string `json:"itemId"` + SummaryIndex int64 `json:"summaryIndex"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type ReasoningSummaryPartAddedNotification struct { + ItemID string `json:"itemId"` + SummaryIndex int64 `json:"summaryIndex"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type ReasoningTextDeltaNotification struct { + ContentIndex int64 `json:"contentIndex"` + Delta string `json:"delta"` + ItemID string `json:"itemId"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type CommandExecutionOutputDeltaNotification struct { + Delta string `json:"delta"` + ItemID string `json:"itemId"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +// Notification that the turn-level unified diff has changed. Contains the latest aggregated +// diff across all file changes in the turn. +type TurnDiffUpdatedNotification struct { + Diff string `json:"diff"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type TurnPlanUpdatedNotification struct { + Explanation *string `json:"explanation"` + Plan []TurnPlanStep `json:"plan"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type TurnPlanStep struct { + Status TurnPlanStepStatus `json:"status"` + Step string `json:"step"` +} + +type ErrorNotification struct { + Error TurnError `json:"error"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + WillRetry bool `json:"willRetry"` +} + +type TurnError struct { + AdditionalDetails *string `json:"additionalDetails"` + CodexErrorInfo *ErrorCodexErrorInfo `json:"codexErrorInfo"` + Message string `json:"message"` +} + +// Failed to connect to the response SSE stream. +// +// The response SSE stream disconnected in the middle of a turn before completion. +// +// Reached the retry limit for responses. +// +// Returned when `turn/start` or `turn/steer` is submitted while the current active turn +// cannot accept same-turn steering, for example `/review` or manual `/compact`. +type HilariousCodexErrorInfo struct { + HTTPConnectionFailed *HilariousHTTPConnectionFailed `json:"httpConnectionFailed,omitempty"` + ResponseStreamConnectionFailed *HilariousResponseStreamConnectionFailed `json:"responseStreamConnectionFailed,omitempty"` + ResponseStreamDisconnected *HilariousResponseStreamDisconnected `json:"responseStreamDisconnected,omitempty"` + ResponseTooManyFailedAttempts *HilariousResponseTooManyFailedAttempts `json:"responseTooManyFailedAttempts,omitempty"` + ActiveTurnNotSteerable *HilariousActiveTurnNotSteerable `json:"activeTurnNotSteerable,omitempty"` +} + +type HilariousActiveTurnNotSteerable struct { + TurnKind NonSteerableTurnKind `json:"turnKind"` +} + +type HilariousHTTPConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type HilariousResponseStreamConnectionFailed struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type HilariousResponseStreamDisconnected struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type HilariousResponseTooManyFailedAttempts struct { + HTTPStatusCode *int64 `json:"httpStatusCode"` +} + +type WarningNotification struct { + // Concise warning message for the user. + Message string `json:"message"` + // Optional thread target when the warning applies to a specific thread. + ThreadID *string `json:"threadId"` +} + +type ThreadTokenUsageUpdatedNotification struct { + ThreadID string `json:"threadId"` + TokenUsage ThreadTokenUsage `json:"tokenUsage"` + TurnID string `json:"turnId"` +} + +type ThreadTokenUsage struct { + Last TokenUsageBreakdown `json:"last"` + ModelContextWindow *int64 `json:"modelContextWindow"` + Total TokenUsageBreakdown `json:"total"` +} + +type TokenUsageBreakdown struct { + CachedInputTokens int64 `json:"cachedInputTokens"` + CacheWriteInputTokens *int64 `json:"cacheWriteInputTokens,omitempty"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + ReasoningOutputTokens int64 `json:"reasoningOutputTokens"` + TotalTokens int64 `json:"totalTokens"` +} + +type ServerRequestResolvedNotification struct { + RequestID *RequestID `json:"requestId"` + ThreadID string `json:"threadId"` +} + +type CommandExecutionRequestApprovalParams struct { + // Optional additional permissions requested for this command. + AdditionalPermissions *AdditionalPermissionProfile `json:"additionalPermissions"` + // Unique identifier for this specific approval callback. + // + // For regular shell/unified_exec approvals, this is null. + // + // For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent + // `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate + // routing. + ApprovalID *string `json:"approvalId"` + // Ordered list of decisions the client may present for this prompt. + AvailableDecisions []CommandExecutionApprovalDecisionElement `json:"availableDecisions"` + // The command to be executed. + Command *string `json:"command"` + // Best-effort parsed command actions for friendly display. + CommandActions []CommandExecutionRequestApprovalParamsCommandAction `json:"commandActions"` + // The command's working directory. + Cwd *string `json:"cwd"` + // Environment in which the command will run. + EnvironmentID *string `json:"environmentId"` + ItemID string `json:"itemId"` + // Optional context for a managed-network approval prompt. + NetworkApprovalContext *NetworkApprovalContext `json:"networkApprovalContext"` + // Optional proposed execpolicy amendment to allow similar commands without prompting. + ProposedExecpolicyAmendment []string `json:"proposedExecpolicyAmendment"` + // Optional proposed network policy amendments (allow/deny host) for future requests. + ProposedNetworkPolicyAmendments []ProposedNetworkPolicyAmendmentElement `json:"proposedNetworkPolicyAmendments"` + // Optional explanatory reason (e.g. request for network access). + Reason *string `json:"reason"` + // Unix timestamp (in milliseconds) when this approval request started. + StartedAtMS int64 `json:"startedAtMs"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type AdditionalPermissionProfile struct { + FileSystem *AdditionalPermissionProfileAdditionalFileSystemPermissions `json:"fileSystem"` + // Partial overlay used for per-command permission requests. + Network *AdditionalPermissionProfileAdditionalNetworkPermissions `json:"network"` +} + +type AdditionalPermissionProfileAdditionalFileSystemPermissions struct { + Entries []PurpleFileSystemSandboxEntry `json:"entries"` + GlobScanMaxDepth *int64 `json:"globScanMaxDepth"` + // This will be removed in favor of `entries`. + Read []string `json:"read"` + // This will be removed in favor of `entries`. + Write []string `json:"write"` +} + +type PurpleFileSystemSandboxEntry struct { + Access FileSystemAccessMode `json:"access"` + Path PurpleFileSystemPath `json:"path"` +} + +type PurpleFileSystemPath struct { + Path *string `json:"path,omitempty"` + Type FileSystemPathType `json:"type"` + Pattern *string `json:"pattern,omitempty"` + Value *PurpleFileSystemSpecialPath `json:"value,omitempty"` +} + +type PurpleFileSystemSpecialPath struct { + Kind Kind `json:"kind"` + Subpath *string `json:"subpath"` + Path *string `json:"path,omitempty"` +} + +type AdditionalPermissionProfileAdditionalNetworkPermissions struct { + Enabled *bool `json:"enabled"` +} + +// User approved the command, and wants to apply the proposed execpolicy amendment so future +// matching commands can run without prompting. +// +// User chose a persistent network policy rule (allow/deny) for this host. +type PurplePolicyAmendmentCommandExecutionApprovalDecision struct { + AcceptWithExecpolicyAmendment *PurpleAcceptWithExecpolicyAmendment `json:"acceptWithExecpolicyAmendment,omitempty"` + ApplyNetworkPolicyAmendment *PurpleApplyNetworkPolicyAmendment `json:"applyNetworkPolicyAmendment,omitempty"` +} + +type PurpleAcceptWithExecpolicyAmendment struct { + ExecpolicyAmendment []string `json:"execpolicy_amendment"` +} + +type PurpleApplyNetworkPolicyAmendment struct { + NetworkPolicyAmendment ProposedNetworkPolicyAmendmentElement `json:"network_policy_amendment"` +} + +type ProposedNetworkPolicyAmendmentElement struct { + Action NetworkPolicyRuleAction `json:"action"` + Host string `json:"host"` +} + +type CommandExecutionRequestApprovalParamsCommandAction struct { + Command string `json:"command"` + Name *string `json:"name,omitempty"` + Path *string `json:"path"` + Type CommandActionType `json:"type"` + Query *string `json:"query"` +} + +type NetworkApprovalContext struct { + Host string `json:"host"` + Protocol NetworkApprovalProtocol `json:"protocol"` +} + +type CommandExecutionRequestApprovalResponse struct { + Decision *CommandExecutionRequestApprovalResponseCommandExecutionApprovalDecision `json:"decision"` +} + +// User approved the command, and wants to apply the proposed execpolicy amendment so future +// matching commands can run without prompting. +// +// User chose a persistent network policy rule (allow/deny) for this host. +type FluffyPolicyAmendmentCommandExecutionApprovalDecision struct { + AcceptWithExecpolicyAmendment *FluffyAcceptWithExecpolicyAmendment `json:"acceptWithExecpolicyAmendment,omitempty"` + ApplyNetworkPolicyAmendment *FluffyApplyNetworkPolicyAmendment `json:"applyNetworkPolicyAmendment,omitempty"` +} + +type FluffyAcceptWithExecpolicyAmendment struct { + ExecpolicyAmendment []string `json:"execpolicy_amendment"` +} + +type FluffyApplyNetworkPolicyAmendment struct { + NetworkPolicyAmendment PurpleNetworkPolicyAmendment `json:"network_policy_amendment"` +} + +type PurpleNetworkPolicyAmendment struct { + Action NetworkPolicyRuleAction `json:"action"` + Host string `json:"host"` +} + +type FileChangeRequestApprovalParams struct { + // [UNSTABLE] When set, the agent is asking the user to allow writes under this root for the + // remainder of the session (unclear if this is honored today). + GrantRoot *string `json:"grantRoot"` + ItemID string `json:"itemId"` + // Optional explanatory reason (e.g. request for extra write access). + Reason *string `json:"reason"` + // Unix timestamp (in milliseconds) when this approval request started. + StartedAtMS int64 `json:"startedAtMs"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type FileChangeRequestApprovalResponse struct { + Decision FileChangeApprovalDecision `json:"decision"` +} + +type PermissionsRequestApprovalParams struct { + Cwd string `json:"cwd"` + EnvironmentID *string `json:"environmentId"` + ItemID string `json:"itemId"` + Permissions RequestPermissionProfile `json:"permissions"` + Reason *string `json:"reason"` + // Unix timestamp (in milliseconds) when this approval request started. + StartedAtMS int64 `json:"startedAtMs"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type RequestPermissionProfile struct { + FileSystem *PurpleAdditionalFileSystemPermissions `json:"fileSystem"` + Network *PurpleAdditionalNetworkPermissions `json:"network"` +} + +type PurpleAdditionalFileSystemPermissions struct { + Entries []FluffyFileSystemSandboxEntry `json:"entries"` + GlobScanMaxDepth *int64 `json:"globScanMaxDepth"` + // This will be removed in favor of `entries`. + Read []string `json:"read"` + // This will be removed in favor of `entries`. + Write []string `json:"write"` +} + +type FluffyFileSystemSandboxEntry struct { + Access FileSystemAccessMode `json:"access"` + Path FluffyFileSystemPath `json:"path"` +} + +type FluffyFileSystemPath struct { + Path *string `json:"path,omitempty"` + Type FileSystemPathType `json:"type"` + Pattern *string `json:"pattern,omitempty"` + Value *FluffyFileSystemSpecialPath `json:"value,omitempty"` +} + +type FluffyFileSystemSpecialPath struct { + Kind Kind `json:"kind"` + Subpath *string `json:"subpath"` + Path *string `json:"path,omitempty"` +} + +type PurpleAdditionalNetworkPermissions struct { + Enabled *bool `json:"enabled"` +} + +type PermissionsRequestApprovalResponse struct { + Permissions GrantedPermissionProfile `json:"permissions"` + Scope *PermissionGrantScope `json:"scope,omitempty"` + // Review every subsequent command in this turn before normal sandboxed execution. + StrictAutoReview *bool `json:"strictAutoReview"` +} + +type GrantedPermissionProfile struct { + FileSystem *FluffyAdditionalFileSystemPermissions `json:"fileSystem"` + Network *FluffyAdditionalNetworkPermissions `json:"network"` +} + +type FluffyAdditionalFileSystemPermissions struct { + Entries []TentacledFileSystemSandboxEntry `json:"entries"` + GlobScanMaxDepth *int64 `json:"globScanMaxDepth"` + // This will be removed in favor of `entries`. + Read []string `json:"read"` + // This will be removed in favor of `entries`. + Write []string `json:"write"` +} + +type TentacledFileSystemSandboxEntry struct { + Access FileSystemAccessMode `json:"access"` + Path TentacledFileSystemPath `json:"path"` +} + +type TentacledFileSystemPath struct { + Path *string `json:"path,omitempty"` + Type FileSystemPathType `json:"type"` + Pattern *string `json:"pattern,omitempty"` + Value *TentacledFileSystemSpecialPath `json:"value,omitempty"` +} + +type TentacledFileSystemSpecialPath struct { + Kind Kind `json:"kind"` + Subpath *string `json:"subpath"` + Path *string `json:"path,omitempty"` +} + +type FluffyAdditionalNetworkPermissions struct { + Enabled *bool `json:"enabled"` +} + +// EXPERIMENTAL. Params sent with a request_user_input event. +type ToolRequestUserInputParams struct { + AutoResolutionMS *int64 `json:"autoResolutionMs"` + ItemID string `json:"itemId"` + Questions []ToolRequestUserInputQuestion `json:"questions"` + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +// EXPERIMENTAL. Represents one request_user_input question and its required options. +type ToolRequestUserInputQuestion struct { + Header string `json:"header"` + ID string `json:"id"` + IsOther *bool `json:"isOther,omitempty"` + IsSecret *bool `json:"isSecret,omitempty"` + Options []ToolRequestUserInputOption `json:"options"` + Question string `json:"question"` +} + +// EXPERIMENTAL. Defines a single selectable option for request_user_input. +type ToolRequestUserInputOption struct { + Description string `json:"description"` + Label string `json:"label"` +} + +// EXPERIMENTAL. Response payload mapping question ids to answers. +type ToolRequestUserInputResponse struct { + Answers map[string]ToolRequestUserInputAnswer `json:"answers"` +} + +// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +type ToolRequestUserInputAnswer struct { + Answers []string `json:"answers"` +} + +type MCPServerElicitationRequestParams struct { + ServerName string `json:"serverName"` + ThreadID string `json:"threadId"` + // Active Codex turn when this elicitation was observed, if app-server could correlate one. + // + // This is nullable because MCP models elicitation as a standalone server-to-client request + // identified by the MCP server request id. It may be triggered during a turn, but turn + // context is app-server correlation rather than part of the protocol identity of the + // elicitation itself. + TurnID *string `json:"turnId"` + Meta interface{} `json:"_meta"` + Message string `json:"message"` + Mode Mode `json:"mode"` + RequestedSchema interface{} `json:"requestedSchema"` + ElicitationID *string `json:"elicitationId,omitempty"` + URL *string `json:"url,omitempty"` +} + +type MCPServerElicitationRequestResponse struct { + // Optional client metadata for form-mode action handling. + Meta interface{} `json:"_meta"` + Action MCPServerElicitationAction `json:"action"` + // Structured user input for accepted elicitations, mirroring RMCP + // `CreateElicitationResult`. + // + // This is nullable because decline/cancel responses have no content. + Content interface{} `json:"content"` +} + +// Canonical user-input modality tags advertised by a model. +// +// Plain text turns and tool payloads. +// +// Image attachments included in user turns. +// +// Audio attachments included in user turns. +type InputModality string + +const ( + InputModalityAudio InputModality = "audio" + InputModalityImage InputModality = "image" + InputModalityText InputModality = "text" +) + +type ApprovalPolicyEnum string + +const ( + AskForApprovalUntrusted ApprovalPolicyEnum = "untrusted" + Never ApprovalPolicyEnum = "never" + OnRequest ApprovalPolicyEnum = "on-request" +) + +// Configures who approval requests are routed to for review. Examples include sandbox +// escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to +// `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and +// apply a risk-based decision framework before approving or denying the request. The legacy +// value `guardian_subagent` is accepted for compatibility. +// +// Reviewer currently used for approval requests on this thread. +type ApprovalsReviewer string + +const ( + AutoReview ApprovalsReviewer = "auto_review" + GuardianSubagent ApprovalsReviewer = "guardian_subagent" + User ApprovalsReviewer = "user" +) + +type FunctionDynamicToolNamespaceToolType string + +const ( + FunctionDynamicToolNamespaceToolTypeFunction FunctionDynamicToolNamespaceToolType = "function" +) + +type DynamicToolSpecType string + +const ( + DynamicToolSpecTypeFunction DynamicToolSpecType = "function" + Namespace DynamicToolSpecType = "namespace" +) + +// Persisted thread history contract selected when this thread was created. +type ThreadHistoryMode string + +const ( + Legacy ThreadHistoryMode = "legacy" + Paginated ThreadHistoryMode = "paginated" +) + +type MultiAgentModeEnum string + +const ( + ExplicitRequestOnly MultiAgentModeEnum = "explicitRequestOnly" + Proactive MultiAgentModeEnum = "proactive" +) + +type Personality string + +const ( + Friendly Personality = "friendly" + PersonalityNone Personality = "none" + Pragmatic Personality = "pragmatic" +) + +type SandboxMode string + +const ( + DangerFullAccess SandboxMode = "danger-full-access" + ReadOnly SandboxMode = "read-only" + WorkspaceWrite SandboxMode = "workspace-write" +) + +type EnvironmentCapabilityRootLocationType string + +const ( + Environment EnvironmentCapabilityRootLocationType = "environment" +) + +type ThreadStartSource string + +const ( + Clear ThreadStartSource = "clear" + Startup ThreadStartSource = "startup" +) + +type NetworkAccess string + +const ( + Enabled NetworkAccess = "enabled" + Restricted NetworkAccess = "restricted" +) + +type SandboxPolicyType string + +const ( + ExternalSandbox SandboxPolicyType = "externalSandbox" + SandboxPolicyTypeDangerFullAccess SandboxPolicyType = "dangerFullAccess" + SandboxPolicyTypeReadOnly SandboxPolicyType = "readOnly" + SandboxPolicyTypeWorkspaceWrite SandboxPolicyType = "workspaceWrite" +) + +type SubAgentSource string + +const ( + MemoryConsolidation SubAgentSource = "memory_consolidation" + SubAgentSourceCompact SubAgentSource = "compact" + SubAgentSourceReview SubAgentSource = "review" +) + +type SessionSource string + +const ( + AppServer SessionSource = "appServer" + CLI SessionSource = "cli" + SessionSourceExec SessionSource = "exec" + SessionSourceUnknown SessionSource = "unknown" + Vscode SessionSource = "vscode" +) + +type ThreadActiveFlag string + +const ( + WaitingOnApproval ThreadActiveFlag = "waitingOnApproval" + WaitingOnUserInput ThreadActiveFlag = "waitingOnUserInput" +) + +type ThreadStatusType string + +const ( + Active ThreadStatusType = "active" + Idle ThreadStatusType = "idle" + SystemError ThreadStatusType = "systemError" + ThreadStatusTypeNotLoaded ThreadStatusType = "notLoaded" +) + +type NonSteerableTurnKind string + +const ( + NonSteerableTurnKindCompact NonSteerableTurnKind = "compact" + NonSteerableTurnKindReview NonSteerableTurnKind = "review" +) + +type CodexErrorInfoEnum string + +const ( + BadRequest CodexErrorInfoEnum = "badRequest" + CodexErrorInfoOther CodexErrorInfoEnum = "other" + ContextWindowExceeded CodexErrorInfoEnum = "contextWindowExceeded" + CyberPolicy CodexErrorInfoEnum = "cyberPolicy" + InternalServerError CodexErrorInfoEnum = "internalServerError" + SandboxError CodexErrorInfoEnum = "sandboxError" + ServerOverloaded CodexErrorInfoEnum = "serverOverloaded" + SessionBudgetExceeded CodexErrorInfoEnum = "sessionBudgetExceeded" + ThreadRollbackFailed CodexErrorInfoEnum = "threadRollbackFailed" + Unauthorized CodexErrorInfoEnum = "unauthorized" + UsageLimitExceeded CodexErrorInfoEnum = "usageLimitExceeded" +) + +type WebSearchActionType string + +const ( + FindInPage WebSearchActionType = "findInPage" + OpenPage WebSearchActionType = "openPage" + WebSearchActionTypeOther WebSearchActionType = "other" + WebSearchActionTypeSearch WebSearchActionType = "search" +) + +type CollabAgentStatus string + +const ( + CollabAgentStatusCompleted CollabAgentStatus = "completed" + CollabAgentStatusInterrupted CollabAgentStatus = "interrupted" + Errored CollabAgentStatus = "errored" + NotFound CollabAgentStatus = "notFound" + PendingInit CollabAgentStatus = "pendingInit" + Running CollabAgentStatus = "running" + Shutdown CollabAgentStatus = "shutdown" +) + +type PatchChangeKindType string + +const ( + Add PatchChangeKindType = "add" + Delete PatchChangeKindType = "delete" + Update PatchChangeKindType = "update" +) + +type CommandActionType string + +const ( + CommandActionTypeRead CommandActionType = "read" + CommandActionTypeSearch CommandActionType = "search" + CommandActionTypeUnknown CommandActionType = "unknown" + ListFiles CommandActionType = "listFiles" +) + +type ImageDetail string + +const ( + High ImageDetail = "high" + ImageDetailAuto ImageDetail = "auto" + Low ImageDetail = "low" + Original ImageDetail = "original" +) + +type UserInputType string + +const ( + LocalAudio UserInputType = "localAudio" + LocalImage UserInputType = "localImage" + Mention UserInputType = "mention" + Skill UserInputType = "skill" + UserInputTypeAudio UserInputType = "audio" + UserInputTypeImage UserInputType = "image" + UserInputTypeText UserInputType = "text" +) + +type InputDynamicToolCallOutputContentItemType string + +const ( + InputAudio InputDynamicToolCallOutputContentItemType = "inputAudio" + InputImage InputDynamicToolCallOutputContentItemType = "inputImage" + InputText InputDynamicToolCallOutputContentItemType = "inputText" +) + +type SubAgentActivityKind string + +const ( + Interacted SubAgentActivityKind = "interacted" + Started SubAgentActivityKind = "started" + SubAgentActivityKindInterrupted SubAgentActivityKind = "interrupted" +) + +// Mid-turn assistant text (for example preamble/progress narration). +// +// Additional tool calls or assistant output may follow before turn completion. +// +// The assistant's terminal answer text for the current turn. +type MessagePhase string + +const ( + Commentary MessagePhase = "commentary" + FinalAnswer MessagePhase = "final_answer" +) + +type CommandExecutionSource string + +const ( + Agent CommandExecutionSource = "agent" + UnifiedExecInteraction CommandExecutionSource = "unifiedExecInteraction" + UnifiedExecStartup CommandExecutionSource = "unifiedExecStartup" + UserShell CommandExecutionSource = "userShell" +) + +type ThreadItemType string + +const ( + AgentMessage ThreadItemType = "agentMessage" + CollabAgentToolCall ThreadItemType = "collabAgentToolCall" + CommandExecution ThreadItemType = "commandExecution" + ContextCompaction ThreadItemType = "contextCompaction" + DynamicToolCall ThreadItemType = "dynamicToolCall" + EnteredReviewMode ThreadItemType = "enteredReviewMode" + ExitedReviewMode ThreadItemType = "exitedReviewMode" + FileChange ThreadItemType = "fileChange" + HookPrompt ThreadItemType = "hookPrompt" + ImageGeneration ThreadItemType = "imageGeneration" + ImageView ThreadItemType = "imageView" + MCPToolCall ThreadItemType = "mcpToolCall" + Sleep ThreadItemType = "sleep" + SubAgentActivity ThreadItemType = "subAgentActivity" + ThreadItemTypePlan ThreadItemType = "plan" + ThreadItemTypeReasoning ThreadItemType = "reasoning" + UserMessage ThreadItemType = "userMessage" + WebSearch ThreadItemType = "webSearch" +) + +// Describes how much of `items` has been loaded for this turn. +// +// `items` was not loaded for this turn. The field is intentionally empty. +// +// `items` contains only a display summary for this turn. +// +// `items` contains every ThreadItem available from persisted app-server history for this +// turn. +type TurnItemsView string + +const ( + Full TurnItemsView = "full" + Summary TurnItemsView = "summary" + TurnItemsViewNotLoaded TurnItemsView = "notLoaded" +) + +type TurnStatus string + +const ( + Failed TurnStatus = "failed" + TurnStatusCompleted TurnStatus = "completed" + TurnStatusInProgress TurnStatus = "inProgress" + TurnStatusInterrupted TurnStatus = "interrupted" +) + +type ActionType string + +const ( + ActionTypeExec ActionType = "exec" + ActionTypeFindInPage ActionType = "find_in_page" + ActionTypeOpenPage ActionType = "open_page" + ActionTypeOther ActionType = "other" + ActionTypeSearch ActionType = "search" +) + +type Type string + +const ( + OutputText Type = "output_text" + ReasoningText Type = "reasoning_text" + TypeEncryptedContent Type = "encrypted_content" + TypeInputAudio Type = "input_audio" + TypeInputImage Type = "input_image" + TypeInputText Type = "input_text" + TypeText Type = "text" +) + +type FunctionCallOutputContentItemType string + +const ( + FunctionCallOutputContentItemTypeEncryptedContent FunctionCallOutputContentItemType = "encrypted_content" + FunctionCallOutputContentItemTypeInputAudio FunctionCallOutputContentItemType = "input_audio" + FunctionCallOutputContentItemTypeInputImage FunctionCallOutputContentItemType = "input_image" + FunctionCallOutputContentItemTypeInputText FunctionCallOutputContentItemType = "input_text" +) + +type SummaryTextReasoningItemReasoningSummaryType string + +const ( + SummaryText SummaryTextReasoningItemReasoningSummaryType = "summary_text" +) + +type ResponseItemType string + +const ( + Compaction ResponseItemType = "compaction" + CompactionTrigger ResponseItemType = "compaction_trigger" + CustomToolCall ResponseItemType = "custom_tool_call" + CustomToolCallOutput ResponseItemType = "custom_tool_call_output" + FunctionCall ResponseItemType = "function_call" + FunctionCallOutput ResponseItemType = "function_call_output" + ImageGenerationCall ResponseItemType = "image_generation_call" + LocalShellCall ResponseItemType = "local_shell_call" + Message ResponseItemType = "message" + ResponseItemTypeAgentMessage ResponseItemType = "agent_message" + ResponseItemTypeContextCompaction ResponseItemType = "context_compaction" + ResponseItemTypeOther ResponseItemType = "other" + ResponseItemTypeReasoning ResponseItemType = "reasoning" + ToolSearchCall ResponseItemType = "tool_search_call" + ToolSearchOutput ResponseItemType = "tool_search_output" + WebSearchCall ResponseItemType = "web_search_call" +) + +type SortDirection string + +const ( + Asc SortDirection = "asc" + Desc SortDirection = "desc" +) + +type AdditionalContextKind string + +const ( + AdditionalContextKindUntrusted AdditionalContextKind = "untrusted" + Application AdditionalContextKind = "application" +) + +// Initial collaboration mode to use when the TUI starts. +type ModeKind string + +const ( + Default ModeKind = "default" + ModeKindPlan ModeKind = "plan" +) + +// Option to disable reasoning summaries. +type ReasoningSummary string + +const ( + Concise ReasoningSummary = "concise" + Detailed ReasoningSummary = "detailed" + ReasoningSummaryAuto ReasoningSummary = "auto" + ReasoningSummaryNone ReasoningSummary = "none" +) + +type TurnPlanStepStatus string + +const ( + Pending TurnPlanStepStatus = "pending" + TurnPlanStepStatusCompleted TurnPlanStepStatus = "completed" + TurnPlanStepStatusInProgress TurnPlanStepStatus = "inProgress" +) + +type FileSystemAccessMode string + +const ( + FileSystemAccessModeDeny FileSystemAccessMode = "deny" + FileSystemAccessModeRead FileSystemAccessMode = "read" + Write FileSystemAccessMode = "write" +) + +type FileSystemPathType string + +const ( + GlobPattern FileSystemPathType = "glob_pattern" + Path FileSystemPathType = "path" + Special FileSystemPathType = "special" +) + +type Kind string + +const ( + KindUnknown Kind = "unknown" + Minimal Kind = "minimal" + ProjectRoots Kind = "project_roots" + Root Kind = "root" + SlashTmp Kind = "slash_tmp" + Tmpdir Kind = "tmpdir" +) + +type NetworkPolicyRuleAction string + +const ( + Allow NetworkPolicyRuleAction = "allow" + NetworkPolicyRuleActionDeny NetworkPolicyRuleAction = "deny" +) + +// User approved the command. +// +// User approved the command and future prompts in the same session-scoped approval cache +// should run without prompting. +// +// User denied the command. The agent will continue the turn. +// +// User denied the command. The turn will also be immediately interrupted. +// +// User approved the file changes. +// +// User approved the file changes and future changes to the same files should run without +// prompting. +// +// User denied the file changes. The agent will continue the turn. +// +// User denied the file changes. The turn will also be immediately interrupted. +type FileChangeApprovalDecision string + +const ( + AcceptForSession FileChangeApprovalDecision = "acceptForSession" + FileChangeApprovalDecisionAccept FileChangeApprovalDecision = "accept" + FileChangeApprovalDecisionCancel FileChangeApprovalDecision = "cancel" + FileChangeApprovalDecisionDecline FileChangeApprovalDecision = "decline" +) + +type NetworkApprovalProtocol string + +const ( + HTTP NetworkApprovalProtocol = "http" + HTTPS NetworkApprovalProtocol = "https" + Socks5TCP NetworkApprovalProtocol = "socks5Tcp" + Socks5UDP NetworkApprovalProtocol = "socks5Udp" +) + +type PermissionGrantScope string + +const ( + Session PermissionGrantScope = "session" + Turn PermissionGrantScope = "turn" +) + +type Mode string + +const ( + Form Mode = "form" + OpenaiForm Mode = "openai/form" + URL Mode = "url" +) + +type MCPServerElicitationAction string + +const ( + MCPServerElicitationActionAccept MCPServerElicitationAction = "accept" + MCPServerElicitationActionCancel MCPServerElicitationAction = "cancel" + MCPServerElicitationActionDecline MCPServerElicitationAction = "decline" +) + +type ThreadStartParamsApprovalPolicy struct { + Enum *ApprovalPolicyEnum + PurpleGranularAskForApproval *PurpleGranularAskForApproval +} + +func (x *ThreadStartParamsApprovalPolicy) UnmarshalJSON(data []byte) error { + x.PurpleGranularAskForApproval = nil + x.Enum = nil + var c PurpleGranularAskForApproval + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.PurpleGranularAskForApproval = &c + } + return nil +} + +func (x *ThreadStartParamsApprovalPolicy) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.PurpleGranularAskForApproval != nil, x.PurpleGranularAskForApproval, false, nil, x.Enum != nil, x.Enum, true) +} + +// @deprecated Ignored. Use Ultra reasoning effort for proactive multi-agent behavior. +type ThreadStartParamsMultiAgentMode struct { + Enum *MultiAgentModeEnum + PurpleCustomMultiAgentMode *PurpleCustomMultiAgentMode +} + +func (x *ThreadStartParamsMultiAgentMode) UnmarshalJSON(data []byte) error { + x.PurpleCustomMultiAgentMode = nil + x.Enum = nil + var c PurpleCustomMultiAgentMode + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.PurpleCustomMultiAgentMode = &c + } + return nil +} + +func (x *ThreadStartParamsMultiAgentMode) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.PurpleCustomMultiAgentMode != nil, x.PurpleCustomMultiAgentMode, false, nil, x.Enum != nil, x.Enum, true) +} + +type ThreadStartResponseAskForApproval struct { + Enum *ApprovalPolicyEnum + FluffyGranularAskForApproval *FluffyGranularAskForApproval +} + +func (x *ThreadStartResponseAskForApproval) UnmarshalJSON(data []byte) error { + x.FluffyGranularAskForApproval = nil + x.Enum = nil + var c FluffyGranularAskForApproval + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.FluffyGranularAskForApproval = &c + } + return nil +} + +func (x *ThreadStartResponseAskForApproval) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.FluffyGranularAskForApproval != nil, x.FluffyGranularAskForApproval, false, nil, x.Enum != nil, x.Enum, false) +} + +// @deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior. +// +// Controls the effective multi-agent delegation instructions for a turn. `custom` means the +// configured mode hint defines the policy instead of a built-in policy. +type ThreadStartResponseMultiAgentMode struct { + Enum *MultiAgentModeEnum + FluffyCustomMultiAgentMode *FluffyCustomMultiAgentMode +} + +func (x *ThreadStartResponseMultiAgentMode) UnmarshalJSON(data []byte) error { + x.FluffyCustomMultiAgentMode = nil + x.Enum = nil + var c FluffyCustomMultiAgentMode + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.FluffyCustomMultiAgentMode = &c + } + return nil +} + +func (x *ThreadStartResponseMultiAgentMode) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.FluffyCustomMultiAgentMode != nil, x.FluffyCustomMultiAgentMode, false, nil, x.Enum != nil, x.Enum, false) +} + +type NetworkAccessUnion struct { + Bool *bool + Enum *NetworkAccess +} + +func (x *NetworkAccessUnion) UnmarshalJSON(data []byte) error { + x.Enum = nil + object, err := unmarshalUnion(data, nil, nil, &x.Bool, nil, false, nil, false, nil, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + } + return nil +} + +func (x *NetworkAccessUnion) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, x.Bool, nil, false, nil, false, nil, false, nil, x.Enum != nil, x.Enum, false) +} + +// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). +type StickySessionSource struct { + Enum *SessionSource + PurpleSessionSource *PurpleSessionSource +} + +func (x *StickySessionSource) UnmarshalJSON(data []byte) error { + x.PurpleSessionSource = nil + x.Enum = nil + var c PurpleSessionSource + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.PurpleSessionSource = &c + } + return nil +} + +func (x *StickySessionSource) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.PurpleSessionSource != nil, x.PurpleSessionSource, false, nil, x.Enum != nil, x.Enum, false) +} + +type StickySubAgentSource struct { + Enum *SubAgentSource + PurpleSubAgentSource *PurpleSubAgentSource +} + +func (x *StickySubAgentSource) UnmarshalJSON(data []byte) error { + x.PurpleSubAgentSource = nil + x.Enum = nil + var c PurpleSubAgentSource + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.PurpleSubAgentSource = &c + } + return nil +} + +func (x *StickySubAgentSource) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.PurpleSubAgentSource != nil, x.PurpleSubAgentSource, false, nil, x.Enum != nil, x.Enum, false) +} + +type AmbitiousCodexErrorInfo struct { + Enum *CodexErrorInfoEnum + PurpleCodexErrorInfo *PurpleCodexErrorInfo +} + +func (x *AmbitiousCodexErrorInfo) UnmarshalJSON(data []byte) error { + x.PurpleCodexErrorInfo = nil + x.Enum = nil + var c PurpleCodexErrorInfo + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.PurpleCodexErrorInfo = &c + } + return nil +} + +func (x *AmbitiousCodexErrorInfo) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.PurpleCodexErrorInfo != nil, x.PurpleCodexErrorInfo, false, nil, x.Enum != nil, x.Enum, true) +} + +type CunningUserInput struct { + PurpleUserInput *PurpleUserInput + String *string +} + +func (x *CunningUserInput) UnmarshalJSON(data []byte) error { + x.PurpleUserInput = nil + var c PurpleUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.PurpleUserInput = &c + } + return nil +} + +func (x *CunningUserInput) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.PurpleUserInput != nil, x.PurpleUserInput, false, nil, false, nil, false) +} + +type PurpleResult struct { + PurpleMCPToolCallResult *PurpleMCPToolCallResult + String *string +} + +func (x *PurpleResult) UnmarshalJSON(data []byte) error { + x.PurpleMCPToolCallResult = nil + var c PurpleMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.PurpleMCPToolCallResult = &c + } + return nil +} + +func (x *PurpleResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.PurpleMCPToolCallResult != nil, x.PurpleMCPToolCallResult, false, nil, false, nil, true) +} + +type ThreadResumeParamsApprovalPolicy struct { + Enum *ApprovalPolicyEnum + TentacledGranularAskForApproval *TentacledGranularAskForApproval +} + +func (x *ThreadResumeParamsApprovalPolicy) UnmarshalJSON(data []byte) error { + x.TentacledGranularAskForApproval = nil + x.Enum = nil + var c TentacledGranularAskForApproval + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.TentacledGranularAskForApproval = &c + } + return nil +} + +func (x *ThreadResumeParamsApprovalPolicy) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.TentacledGranularAskForApproval != nil, x.TentacledGranularAskForApproval, false, nil, x.Enum != nil, x.Enum, true) +} + +type FunctionCallOutputBody struct { + FunctionCallOutputContentItemArray []FunctionCallOutputContentItem + String *string +} + +func (x *FunctionCallOutputBody) UnmarshalJSON(data []byte) error { + x.FunctionCallOutputContentItemArray = nil + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, true, &x.FunctionCallOutputContentItemArray, false, nil, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + } + return nil +} + +func (x *FunctionCallOutputBody) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, x.FunctionCallOutputContentItemArray != nil, x.FunctionCallOutputContentItemArray, false, nil, false, nil, false, nil, false) +} + +type ThreadResumeResponseAskForApproval struct { + Enum *ApprovalPolicyEnum + StickyGranularAskForApproval *StickyGranularAskForApproval +} + +func (x *ThreadResumeResponseAskForApproval) UnmarshalJSON(data []byte) error { + x.StickyGranularAskForApproval = nil + x.Enum = nil + var c StickyGranularAskForApproval + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.StickyGranularAskForApproval = &c + } + return nil +} + +func (x *ThreadResumeResponseAskForApproval) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.StickyGranularAskForApproval != nil, x.StickyGranularAskForApproval, false, nil, x.Enum != nil, x.Enum, false) +} + +type CunningCodexErrorInfo struct { + Enum *CodexErrorInfoEnum + FluffyCodexErrorInfo *FluffyCodexErrorInfo +} + +func (x *CunningCodexErrorInfo) UnmarshalJSON(data []byte) error { + x.FluffyCodexErrorInfo = nil + x.Enum = nil + var c FluffyCodexErrorInfo + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.FluffyCodexErrorInfo = &c + } + return nil +} + +func (x *CunningCodexErrorInfo) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.FluffyCodexErrorInfo != nil, x.FluffyCodexErrorInfo, false, nil, x.Enum != nil, x.Enum, true) +} + +type MagentaUserInput struct { + FluffyUserInput *FluffyUserInput + String *string +} + +func (x *MagentaUserInput) UnmarshalJSON(data []byte) error { + x.FluffyUserInput = nil + var c FluffyUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.FluffyUserInput = &c + } + return nil +} + +func (x *MagentaUserInput) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.FluffyUserInput != nil, x.FluffyUserInput, false, nil, false, nil, false) +} + +type FluffyResult struct { + FluffyMCPToolCallResult *FluffyMCPToolCallResult + String *string +} + +func (x *FluffyResult) UnmarshalJSON(data []byte) error { + x.FluffyMCPToolCallResult = nil + var c FluffyMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.FluffyMCPToolCallResult = &c + } + return nil +} + +func (x *FluffyResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.FluffyMCPToolCallResult != nil, x.FluffyMCPToolCallResult, false, nil, false, nil, true) +} + +// @deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior. +// +// Controls the effective multi-agent delegation instructions for a turn. `custom` means the +// configured mode hint defines the policy instead of a built-in policy. +type ThreadResumeResponseMultiAgentMode struct { + Enum *MultiAgentModeEnum + TentacledCustomMultiAgentMode *TentacledCustomMultiAgentMode +} + +func (x *ThreadResumeResponseMultiAgentMode) UnmarshalJSON(data []byte) error { + x.TentacledCustomMultiAgentMode = nil + x.Enum = nil + var c TentacledCustomMultiAgentMode + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.TentacledCustomMultiAgentMode = &c + } + return nil +} + +func (x *ThreadResumeResponseMultiAgentMode) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.TentacledCustomMultiAgentMode != nil, x.TentacledCustomMultiAgentMode, false, nil, x.Enum != nil, x.Enum, false) +} + +// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). +type IndigoSessionSource struct { + Enum *SessionSource + FluffySessionSource *FluffySessionSource +} + +func (x *IndigoSessionSource) UnmarshalJSON(data []byte) error { + x.FluffySessionSource = nil + x.Enum = nil + var c FluffySessionSource + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.FluffySessionSource = &c + } + return nil +} + +func (x *IndigoSessionSource) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.FluffySessionSource != nil, x.FluffySessionSource, false, nil, x.Enum != nil, x.Enum, false) +} + +type IndigoSubAgentSource struct { + Enum *SubAgentSource + FluffySubAgentSource *FluffySubAgentSource +} + +func (x *IndigoSubAgentSource) UnmarshalJSON(data []byte) error { + x.FluffySubAgentSource = nil + x.Enum = nil + var c FluffySubAgentSource + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.FluffySubAgentSource = &c + } + return nil +} + +func (x *IndigoSubAgentSource) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.FluffySubAgentSource != nil, x.FluffySubAgentSource, false, nil, x.Enum != nil, x.Enum, false) +} + +// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). +type IndecentSessionSource struct { + Enum *SessionSource + TentacledSessionSource *TentacledSessionSource +} + +func (x *IndecentSessionSource) UnmarshalJSON(data []byte) error { + x.TentacledSessionSource = nil + x.Enum = nil + var c TentacledSessionSource + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.TentacledSessionSource = &c + } + return nil +} + +func (x *IndecentSessionSource) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.TentacledSessionSource != nil, x.TentacledSessionSource, false, nil, x.Enum != nil, x.Enum, false) +} + +type IndecentSubAgentSource struct { + Enum *SubAgentSource + TentacledSubAgentSource *TentacledSubAgentSource +} + +func (x *IndecentSubAgentSource) UnmarshalJSON(data []byte) error { + x.TentacledSubAgentSource = nil + x.Enum = nil + var c TentacledSubAgentSource + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.TentacledSubAgentSource = &c + } + return nil +} + +func (x *IndecentSubAgentSource) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.TentacledSubAgentSource != nil, x.TentacledSubAgentSource, false, nil, x.Enum != nil, x.Enum, false) +} + +type MagentaCodexErrorInfo struct { + Enum *CodexErrorInfoEnum + TentacledCodexErrorInfo *TentacledCodexErrorInfo +} + +func (x *MagentaCodexErrorInfo) UnmarshalJSON(data []byte) error { + x.TentacledCodexErrorInfo = nil + x.Enum = nil + var c TentacledCodexErrorInfo + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.TentacledCodexErrorInfo = &c + } + return nil +} + +func (x *MagentaCodexErrorInfo) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.TentacledCodexErrorInfo != nil, x.TentacledCodexErrorInfo, false, nil, x.Enum != nil, x.Enum, true) +} + +type FriskyUserInput struct { + String *string + TentacledUserInput *TentacledUserInput +} + +func (x *FriskyUserInput) UnmarshalJSON(data []byte) error { + x.TentacledUserInput = nil + var c TentacledUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.TentacledUserInput = &c + } + return nil +} + +func (x *FriskyUserInput) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.TentacledUserInput != nil, x.TentacledUserInput, false, nil, false, nil, false) +} + +type TentacledResult struct { + String *string + TentacledMCPToolCallResult *TentacledMCPToolCallResult +} + +func (x *TentacledResult) UnmarshalJSON(data []byte) error { + x.TentacledMCPToolCallResult = nil + var c TentacledMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.TentacledMCPToolCallResult = &c + } + return nil +} + +func (x *TentacledResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.TentacledMCPToolCallResult != nil, x.TentacledMCPToolCallResult, false, nil, false, nil, true) +} + +// Override the approval policy for this turn and subsequent turns. +type TurnStartParamsApprovalPolicy struct { + Enum *ApprovalPolicyEnum + IndigoGranularAskForApproval *IndigoGranularAskForApproval +} + +func (x *TurnStartParamsApprovalPolicy) UnmarshalJSON(data []byte) error { + x.IndigoGranularAskForApproval = nil + x.Enum = nil + var c IndigoGranularAskForApproval + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.IndigoGranularAskForApproval = &c + } + return nil +} + +func (x *TurnStartParamsApprovalPolicy) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.IndigoGranularAskForApproval != nil, x.IndigoGranularAskForApproval, false, nil, x.Enum != nil, x.Enum, true) +} + +// @deprecated Ignored. Use `effort: "ultra"` for proactive multi-agent behavior. +type TurnStartParamsMultiAgentMode struct { + Enum *MultiAgentModeEnum + StickyCustomMultiAgentMode *StickyCustomMultiAgentMode +} + +func (x *TurnStartParamsMultiAgentMode) UnmarshalJSON(data []byte) error { + x.StickyCustomMultiAgentMode = nil + x.Enum = nil + var c StickyCustomMultiAgentMode + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.StickyCustomMultiAgentMode = &c + } + return nil +} + +func (x *TurnStartParamsMultiAgentMode) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.StickyCustomMultiAgentMode != nil, x.StickyCustomMultiAgentMode, false, nil, x.Enum != nil, x.Enum, true) +} + +type FriskyCodexErrorInfo struct { + Enum *CodexErrorInfoEnum + StickyCodexErrorInfo *StickyCodexErrorInfo +} + +func (x *FriskyCodexErrorInfo) UnmarshalJSON(data []byte) error { + x.StickyCodexErrorInfo = nil + x.Enum = nil + var c StickyCodexErrorInfo + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.StickyCodexErrorInfo = &c + } + return nil +} + +func (x *FriskyCodexErrorInfo) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.StickyCodexErrorInfo != nil, x.StickyCodexErrorInfo, false, nil, x.Enum != nil, x.Enum, true) +} + +type MischievousUserInput struct { + StickyUserInput *StickyUserInput + String *string +} + +func (x *MischievousUserInput) UnmarshalJSON(data []byte) error { + x.StickyUserInput = nil + var c StickyUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.StickyUserInput = &c + } + return nil +} + +func (x *MischievousUserInput) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.StickyUserInput != nil, x.StickyUserInput, false, nil, false, nil, false) +} + +type StickyResult struct { + StickyMCPToolCallResult *StickyMCPToolCallResult + String *string +} + +func (x *StickyResult) UnmarshalJSON(data []byte) error { + x.StickyMCPToolCallResult = nil + var c StickyMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.StickyMCPToolCallResult = &c + } + return nil +} + +func (x *StickyResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.StickyMCPToolCallResult != nil, x.StickyMCPToolCallResult, false, nil, false, nil, true) +} + +type MischievousCodexErrorInfo struct { + Enum *CodexErrorInfoEnum + IndigoCodexErrorInfo *IndigoCodexErrorInfo +} + +func (x *MischievousCodexErrorInfo) UnmarshalJSON(data []byte) error { + x.IndigoCodexErrorInfo = nil + x.Enum = nil + var c IndigoCodexErrorInfo + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.IndigoCodexErrorInfo = &c + } + return nil +} + +func (x *MischievousCodexErrorInfo) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.IndigoCodexErrorInfo != nil, x.IndigoCodexErrorInfo, false, nil, x.Enum != nil, x.Enum, true) +} + +type BraggadociousUserInput struct { + IndigoUserInput *IndigoUserInput + String *string +} + +func (x *BraggadociousUserInput) UnmarshalJSON(data []byte) error { + x.IndigoUserInput = nil + var c IndigoUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.IndigoUserInput = &c + } + return nil +} + +func (x *BraggadociousUserInput) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.IndigoUserInput != nil, x.IndigoUserInput, false, nil, false, nil, false) +} + +type IndigoResult struct { + IndigoMCPToolCallResult *IndigoMCPToolCallResult + String *string +} + +func (x *IndigoResult) UnmarshalJSON(data []byte) error { + x.IndigoMCPToolCallResult = nil + var c IndigoMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.IndigoMCPToolCallResult = &c + } + return nil +} + +func (x *IndigoResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.IndigoMCPToolCallResult != nil, x.IndigoMCPToolCallResult, false, nil, false, nil, true) +} + +type BraggadociousCodexErrorInfo struct { + Enum *CodexErrorInfoEnum + IndecentCodexErrorInfo *IndecentCodexErrorInfo +} + +func (x *BraggadociousCodexErrorInfo) UnmarshalJSON(data []byte) error { + x.IndecentCodexErrorInfo = nil + x.Enum = nil + var c IndecentCodexErrorInfo + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.IndecentCodexErrorInfo = &c + } + return nil +} + +func (x *BraggadociousCodexErrorInfo) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.IndecentCodexErrorInfo != nil, x.IndecentCodexErrorInfo, false, nil, x.Enum != nil, x.Enum, true) +} + +type UserInput1 struct { + IndecentUserInput *IndecentUserInput + String *string +} + +func (x *UserInput1) UnmarshalJSON(data []byte) error { + x.IndecentUserInput = nil + var c IndecentUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.IndecentUserInput = &c + } + return nil +} + +func (x *UserInput1) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.IndecentUserInput != nil, x.IndecentUserInput, false, nil, false, nil, false) +} + +type IndecentResult struct { + IndecentMCPToolCallResult *IndecentMCPToolCallResult + String *string +} + +func (x *IndecentResult) UnmarshalJSON(data []byte) error { + x.IndecentMCPToolCallResult = nil + var c IndecentMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.IndecentMCPToolCallResult = &c + } + return nil +} + +func (x *IndecentResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.IndecentMCPToolCallResult != nil, x.IndecentMCPToolCallResult, false, nil, false, nil, true) +} + +type UserInput2 struct { + HilariousUserInput *HilariousUserInput + String *string +} + +func (x *UserInput2) UnmarshalJSON(data []byte) error { + x.HilariousUserInput = nil + var c HilariousUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.HilariousUserInput = &c + } + return nil +} + +func (x *UserInput2) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.HilariousUserInput != nil, x.HilariousUserInput, false, nil, false, nil, false) +} + +type HilariousResult struct { + HilariousMCPToolCallResult *HilariousMCPToolCallResult + String *string +} + +func (x *HilariousResult) UnmarshalJSON(data []byte) error { + x.HilariousMCPToolCallResult = nil + var c HilariousMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.HilariousMCPToolCallResult = &c + } + return nil +} + +func (x *HilariousResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.HilariousMCPToolCallResult != nil, x.HilariousMCPToolCallResult, false, nil, false, nil, true) +} + +type UserInput3 struct { + AmbitiousUserInput *AmbitiousUserInput + String *string +} + +func (x *UserInput3) UnmarshalJSON(data []byte) error { + x.AmbitiousUserInput = nil + var c AmbitiousUserInput + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.AmbitiousUserInput = &c + } + return nil +} + +func (x *UserInput3) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.AmbitiousUserInput != nil, x.AmbitiousUserInput, false, nil, false, nil, false) +} + +type AmbitiousResult struct { + AmbitiousMCPToolCallResult *AmbitiousMCPToolCallResult + String *string +} + +func (x *AmbitiousResult) UnmarshalJSON(data []byte) error { + x.AmbitiousMCPToolCallResult = nil + var c AmbitiousMCPToolCallResult + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, true) + if err != nil { + return err + } + if object { + x.AmbitiousMCPToolCallResult = &c + } + return nil +} + +func (x *AmbitiousResult) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.AmbitiousMCPToolCallResult != nil, x.AmbitiousMCPToolCallResult, false, nil, false, nil, true) +} + +type ErrorCodexErrorInfo struct { + Enum *CodexErrorInfoEnum + HilariousCodexErrorInfo *HilariousCodexErrorInfo +} + +func (x *ErrorCodexErrorInfo) UnmarshalJSON(data []byte) error { + x.HilariousCodexErrorInfo = nil + x.Enum = nil + var c HilariousCodexErrorInfo + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, true) + if err != nil { + return err + } + if object { + x.HilariousCodexErrorInfo = &c + } + return nil +} + +func (x *ErrorCodexErrorInfo) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.HilariousCodexErrorInfo != nil, x.HilariousCodexErrorInfo, false, nil, x.Enum != nil, x.Enum, true) +} + +type RequestID struct { + Integer *int64 + String *string +} + +func (x *RequestID) UnmarshalJSON(data []byte) error { + object, err := unmarshalUnion(data, &x.Integer, nil, nil, &x.String, false, nil, false, nil, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + } + return nil +} + +func (x *RequestID) MarshalJSON() ([]byte, error) { + return marshalUnion(x.Integer, nil, nil, x.String, false, nil, false, nil, false, nil, false, nil, false) +} + +type CommandExecutionApprovalDecisionElement struct { + Enum *FileChangeApprovalDecision + PurplePolicyAmendmentCommandExecutionApprovalDecision *PurplePolicyAmendmentCommandExecutionApprovalDecision +} + +func (x *CommandExecutionApprovalDecisionElement) UnmarshalJSON(data []byte) error { + x.PurplePolicyAmendmentCommandExecutionApprovalDecision = nil + x.Enum = nil + var c PurplePolicyAmendmentCommandExecutionApprovalDecision + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.PurplePolicyAmendmentCommandExecutionApprovalDecision = &c + } + return nil +} + +func (x *CommandExecutionApprovalDecisionElement) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.PurplePolicyAmendmentCommandExecutionApprovalDecision != nil, x.PurplePolicyAmendmentCommandExecutionApprovalDecision, false, nil, x.Enum != nil, x.Enum, false) +} + +type CommandExecutionRequestApprovalResponseCommandExecutionApprovalDecision struct { + Enum *FileChangeApprovalDecision + FluffyPolicyAmendmentCommandExecutionApprovalDecision *FluffyPolicyAmendmentCommandExecutionApprovalDecision +} + +func (x *CommandExecutionRequestApprovalResponseCommandExecutionApprovalDecision) UnmarshalJSON(data []byte) error { + x.FluffyPolicyAmendmentCommandExecutionApprovalDecision = nil + x.Enum = nil + var c FluffyPolicyAmendmentCommandExecutionApprovalDecision + object, err := unmarshalUnion(data, nil, nil, nil, nil, false, nil, true, &c, false, nil, true, &x.Enum, false) + if err != nil { + return err + } + if object { + x.FluffyPolicyAmendmentCommandExecutionApprovalDecision = &c + } + return nil +} + +func (x *CommandExecutionRequestApprovalResponseCommandExecutionApprovalDecision) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, nil, false, nil, x.FluffyPolicyAmendmentCommandExecutionApprovalDecision != nil, x.FluffyPolicyAmendmentCommandExecutionApprovalDecision, false, nil, x.Enum != nil, x.Enum, false) +} + +func unmarshalUnion(data []byte, pi **int64, pf **float64, pb **bool, ps **string, haveArray bool, pa interface{}, haveObject bool, pc interface{}, haveMap bool, pm interface{}, haveEnum bool, pe interface{}, nullable bool) (bool, error) { + if pi != nil { + *pi = nil + } + if pf != nil { + *pf = nil + } + if pb != nil { + *pb = nil + } + if ps != nil { + *ps = nil + } + + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + tok, err := dec.Token() + if err != nil { + return false, err + } + + switch v := tok.(type) { + case json.Number: + if pi != nil { + i, err := v.Int64() + if err == nil { + *pi = &i + return false, nil + } + } + if pf != nil { + f, err := v.Float64() + if err == nil { + *pf = &f + return false, nil + } + return false, errors.New("Unparsable number") + } + return false, errors.New("Union does not contain number") + case float64: + return false, errors.New("Decoder should not return float64") + case bool: + if pb != nil { + *pb = &v + return false, nil + } + return false, errors.New("Union does not contain bool") + case string: + if haveEnum { + return false, json.Unmarshal(data, pe) + } + if ps != nil { + *ps = &v + return false, nil + } + return false, errors.New("Union does not contain string") + case nil: + if nullable { + return false, nil + } + return false, errors.New("Union does not contain null") + case json.Delim: + if v == '{' { + if haveObject { + return true, json.Unmarshal(data, pc) + } + if haveMap { + return false, json.Unmarshal(data, pm) + } + return false, errors.New("Union does not contain object") + } + if v == '[' { + if haveArray { + return false, json.Unmarshal(data, pa) + } + return false, errors.New("Union does not contain array") + } + return false, errors.New("Cannot handle delimiter") + } + return false, errors.New("Cannot unmarshal union") +} + +func marshalUnion(pi *int64, pf *float64, pb *bool, ps *string, haveArray bool, pa interface{}, haveObject bool, pc interface{}, haveMap bool, pm interface{}, haveEnum bool, pe interface{}, nullable bool) ([]byte, error) { + if pi != nil { + return json.Marshal(*pi) + } + if pf != nil { + return json.Marshal(*pf) + } + if pb != nil { + return json.Marshal(*pb) + } + if ps != nil { + return json.Marshal(*ps) + } + if haveArray { + return json.Marshal(pa) + } + if haveObject { + return json.Marshal(pc) + } + if haveMap { + return json.Marshal(pm) + } + if haveEnum { + return json.Marshal(pe) + } + if nullable { + return json.Marshal(nil) + } + return nil, errors.New("Union must not be null") +} diff --git a/codexapp/internal/protocol/schema-version.txt b/codexapp/internal/protocol/schema-version.txt new file mode 100644 index 0000000..9803995 --- /dev/null +++ b/codexapp/internal/protocol/schema-version.txt @@ -0,0 +1 @@ +codex-cli 0.146.0 diff --git a/codexapp/internal/protocol/schema/CommandExecutionRequestApprovalParams.gen.json b/codexapp/internal/protocol/schema/CommandExecutionRequestApprovalParams.gen.json new file mode 100644 index 0000000..74c3b7d --- /dev/null +++ b/codexapp/internal/protocol/schema/CommandExecutionRequestApprovalParams.gen.json @@ -0,0 +1,656 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecutionRequestApprovalParams", + "type": "object", + "required": [ + "itemId", + "startedAtMs", + "threadId", + "turnId" + ], + "properties": { + "additionalPermissions": { + "description": "Optional additional permissions requested for this command.", + "anyOf": [ + { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalId": { + "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.", + "type": [ + "string", + "null" + ] + }, + "availableDecisions": { + "description": "Ordered list of decisions the client may present for this prompt.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/CommandExecutionApprovalDecision" + } + }, + "command": { + "description": "The command to be executed.", + "type": [ + "string", + "null" + ] + }, + "commandActions": { + "description": "Best-effort parsed command actions for friendly display.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + }, + "environmentId": { + "description": "Environment in which the command will run.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "itemId": { + "type": "string" + }, + "networkApprovalContext": { + "description": "Optional context for a managed-network approval prompt.", + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ] + }, + "proposedExecpolicyAmendment": { + "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "proposedNetworkPolicyAmendments": { + "description": "Optional proposed network policy amendments (allow/deny host) for future requests.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "reason": { + "description": "Optional explanatory reason (e.g. request for network access).", + "type": [ + "string", + "null" + ] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "type": "integer", + "format": "int64" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "type": "object", + "properties": { + "entries": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + } + }, + "globScanMaxDepth": { + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 1.0 + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + } + } + }, + "AdditionalNetworkPermissions": { + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "AdditionalPermissionProfile": { + "type": "object", + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "description": "Partial overlay used for per-command permission requests.", + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + } + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionApprovalDecision": { + "oneOf": [ + { + "description": "User approved the command.", + "type": "string", + "enum": [ + "accept" + ] + }, + { + "description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.", + "type": "string", + "enum": [ + "acceptForSession" + ] + }, + { + "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", + "type": "object", + "required": [ + "acceptWithExecpolicyAmendment" + ], + "properties": { + "acceptWithExecpolicyAmendment": { + "type": "object", + "required": [ + "execpolicy_amendment" + ], + "properties": { + "execpolicy_amendment": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "additionalProperties": false, + "title": "AcceptWithExecpolicyAmendmentCommandExecutionApprovalDecision" + }, + { + "description": "User chose a persistent network policy rule (allow/deny) for this host.", + "type": "object", + "required": [ + "applyNetworkPolicyAmendment" + ], + "properties": { + "applyNetworkPolicyAmendment": { + "type": "object", + "required": [ + "network_policy_amendment" + ], + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + } + } + }, + "additionalProperties": false, + "title": "ApplyNetworkPolicyAmendmentCommandExecutionApprovalDecision" + }, + { + "description": "User denied the command. The agent will continue the turn.", + "type": "string", + "enum": [ + "decline" + ] + }, + { + "description": "User denied the command. The turn will also be immediately interrupted.", + "type": "string", + "enum": [ + "cancel" + ] + } + ] + }, + "FileSystemAccessMode": { + "type": "string", + "enum": [ + "read", + "write", + "deny" + ] + }, + "FileSystemPath": { + "oneOf": [ + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "path" + ], + "title": "PathFileSystemPathType" + } + }, + "title": "PathFileSystemPath" + }, + { + "type": "object", + "required": [ + "pattern", + "type" + ], + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType" + } + }, + "title": "GlobPatternFileSystemPath" + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "title": "SpecialFileSystemPath" + } + ] + }, + "FileSystemSandboxEntry": { + "type": "object", + "required": [ + "access", + "path" + ], + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + } + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "root" + ] + } + }, + "title": "RootFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "minimal" + ] + } + }, + "title": "MinimalFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "project_roots" + ] + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + }, + "title": "KindFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "tmpdir" + ] + } + }, + "title": "TmpdirFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "slash_tmp" + ] + } + }, + "title": "SlashTmpFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "unknown" + ] + }, + "path": { + "type": "string" + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "NetworkApprovalContext": { + "type": "object", + "required": [ + "host", + "protocol" + ], + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + } + }, + "NetworkApprovalProtocol": { + "type": "string", + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ] + }, + "NetworkPolicyAmendment": { + "type": "object", + "required": [ + "action", + "host" + ], + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + } + }, + "NetworkPolicyRuleAction": { + "type": "string", + "enum": [ + "allow", + "deny" + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/CommandExecutionRequestApprovalResponse.gen.json b/codexapp/internal/protocol/schema/CommandExecutionRequestApprovalResponse.gen.json new file mode 100644 index 0000000..60036c0 --- /dev/null +++ b/codexapp/internal/protocol/schema/CommandExecutionRequestApprovalResponse.gen.json @@ -0,0 +1,116 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecutionRequestApprovalResponse", + "type": "object", + "required": [ + "decision" + ], + "properties": { + "decision": { + "$ref": "#/definitions/CommandExecutionApprovalDecision" + } + }, + "definitions": { + "CommandExecutionApprovalDecision": { + "oneOf": [ + { + "description": "User approved the command.", + "type": "string", + "enum": [ + "accept" + ] + }, + { + "description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.", + "type": "string", + "enum": [ + "acceptForSession" + ] + }, + { + "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", + "type": "object", + "required": [ + "acceptWithExecpolicyAmendment" + ], + "properties": { + "acceptWithExecpolicyAmendment": { + "type": "object", + "required": [ + "execpolicy_amendment" + ], + "properties": { + "execpolicy_amendment": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "additionalProperties": false, + "title": "AcceptWithExecpolicyAmendmentCommandExecutionApprovalDecision" + }, + { + "description": "User chose a persistent network policy rule (allow/deny) for this host.", + "type": "object", + "required": [ + "applyNetworkPolicyAmendment" + ], + "properties": { + "applyNetworkPolicyAmendment": { + "type": "object", + "required": [ + "network_policy_amendment" + ], + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + } + } + }, + "additionalProperties": false, + "title": "ApplyNetworkPolicyAmendmentCommandExecutionApprovalDecision" + }, + { + "description": "User denied the command. The agent will continue the turn.", + "type": "string", + "enum": [ + "decline" + ] + }, + { + "description": "User denied the command. The turn will also be immediately interrupted.", + "type": "string", + "enum": [ + "cancel" + ] + } + ] + }, + "NetworkPolicyAmendment": { + "type": "object", + "required": [ + "action", + "host" + ], + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + } + }, + "NetworkPolicyRuleAction": { + "type": "string", + "enum": [ + "allow", + "deny" + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/FileChangeRequestApprovalParams.gen.json b/codexapp/internal/protocol/schema/FileChangeRequestApprovalParams.gen.json new file mode 100644 index 0000000..a8f4fa5 --- /dev/null +++ b/codexapp/internal/protocol/schema/FileChangeRequestApprovalParams.gen.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FileChangeRequestApprovalParams", + "type": "object", + "required": [ + "itemId", + "startedAtMs", + "threadId", + "turnId" + ], + "properties": { + "grantRoot": { + "description": "[UNSTABLE] When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", + "type": [ + "string", + "null" + ] + }, + "itemId": { + "type": "string" + }, + "reason": { + "description": "Optional explanatory reason (e.g. request for extra write access).", + "type": [ + "string", + "null" + ] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "type": "integer", + "format": "int64" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/FileChangeRequestApprovalResponse.gen.json b/codexapp/internal/protocol/schema/FileChangeRequestApprovalResponse.gen.json new file mode 100644 index 0000000..ace7740 --- /dev/null +++ b/codexapp/internal/protocol/schema/FileChangeRequestApprovalResponse.gen.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FileChangeRequestApprovalResponse", + "type": "object", + "required": [ + "decision" + ], + "properties": { + "decision": { + "$ref": "#/definitions/FileChangeApprovalDecision" + } + }, + "definitions": { + "FileChangeApprovalDecision": { + "oneOf": [ + { + "description": "User approved the file changes.", + "type": "string", + "enum": [ + "accept" + ] + }, + { + "description": "User approved the file changes and future changes to the same files should run without prompting.", + "type": "string", + "enum": [ + "acceptForSession" + ] + }, + { + "description": "User denied the file changes. The agent will continue the turn.", + "type": "string", + "enum": [ + "decline" + ] + }, + { + "description": "User denied the file changes. The turn will also be immediately interrupted.", + "type": "string", + "enum": [ + "cancel" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/McpServerElicitationRequestParams.gen.json b/codexapp/internal/protocol/schema/McpServerElicitationRequestParams.gen.json new file mode 100644 index 0000000..79a6df9 --- /dev/null +++ b/codexapp/internal/protocol/schema/McpServerElicitationRequestParams.gen.json @@ -0,0 +1,630 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "McpServerElicitationRequestParams", + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "message", + "mode", + "requestedSchema" + ], + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "form" + ] + }, + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" + } + } + }, + { + "type": "object", + "required": [ + "message", + "mode", + "requestedSchema" + ], + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "openai/form" + ] + }, + "requestedSchema": true + } + }, + { + "type": "object", + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "properties": { + "_meta": true, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "url" + ] + }, + "url": { + "type": "string" + } + } + } + ], + "required": [ + "serverName", + "threadId" + ], + "properties": { + "serverName": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "McpElicitationArrayType": { + "type": "string", + "enum": [ + "array" + ] + }, + "McpElicitationBooleanSchema": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "default": { + "type": [ + "boolean", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "additionalProperties": false + }, + "McpElicitationBooleanType": { + "type": "string", + "enum": [ + "boolean" + ] + }, + "McpElicitationConstOption": { + "type": "object", + "required": [ + "const", + "title" + ], + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "McpElicitationEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "type": "object", + "required": [ + "enum", + "type" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "enumNames": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "additionalProperties": false + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "default": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "maximum": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "minimum": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "additionalProperties": false + }, + "McpElicitationNumberType": { + "type": "string", + "enum": [ + "number", + "integer" + ] + }, + "McpElicitationObjectType": { + "type": "string", + "enum": [ + "object" + ] + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationStringSchema" + }, + { + "$ref": "#/definitions/McpElicitationNumberSchema" + }, + { + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "type": "object", + "required": [ + "properties", + "type" + ], + "properties": { + "$schema": { + "type": [ + "string", + "null" + ] + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" + } + }, + "required": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "additionalProperties": false + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "type": "string", + "enum": [ + "email", + "uri", + "date", + "date-time" + ] + }, + "McpElicitationStringSchema": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "minLength": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "additionalProperties": false + }, + "McpElicitationStringType": { + "type": "string", + "enum": [ + "string" + ] + }, + "McpElicitationTitledEnumItems": { + "type": "object", + "required": [ + "anyOf" + ], + "properties": { + "anyOf": { + "type": "array", + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + } + } + }, + "additionalProperties": false + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "type": "object", + "required": [ + "items", + "type" + ], + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "additionalProperties": false + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "type": "object", + "required": [ + "oneOf", + "type" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "additionalProperties": false + }, + "McpElicitationUntitledEnumItems": { + "type": "object", + "required": [ + "enum", + "type" + ], + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "additionalProperties": false + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "type": "object", + "required": [ + "items", + "type" + ], + "properties": { + "default": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "minItems": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "additionalProperties": false + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "type": "object", + "required": [ + "enum", + "type" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "enum": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/McpServerElicitationRequestResponse.gen.json b/codexapp/internal/protocol/schema/McpServerElicitationRequestResponse.gen.json new file mode 100644 index 0000000..f0fe310 --- /dev/null +++ b/codexapp/internal/protocol/schema/McpServerElicitationRequestResponse.gen.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "McpServerElicitationRequestResponse", + "type": "object", + "required": [ + "action" + ], + "properties": { + "_meta": { + "description": "Optional client metadata for form-mode action handling." + }, + "action": { + "$ref": "#/definitions/McpServerElicitationAction" + }, + "content": { + "description": "Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`.\n\nThis is nullable because decline/cancel responses have no content." + } + }, + "definitions": { + "McpServerElicitationAction": { + "type": "string", + "enum": [ + "accept", + "decline", + "cancel" + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/PermissionsRequestApprovalParams.gen.json b/codexapp/internal/protocol/schema/PermissionsRequestApprovalParams.gen.json new file mode 100644 index 0000000..5ec62e4 --- /dev/null +++ b/codexapp/internal/protocol/schema/PermissionsRequestApprovalParams.gen.json @@ -0,0 +1,340 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionsRequestApprovalParams", + "type": "object", + "required": [ + "cwd", + "itemId", + "permissions", + "startedAtMs", + "threadId", + "turnId" + ], + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "type": "integer", + "format": "int64" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "type": "object", + "properties": { + "entries": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + } + }, + "globScanMaxDepth": { + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 1.0 + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + } + } + }, + "AdditionalNetworkPermissions": { + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "FileSystemAccessMode": { + "type": "string", + "enum": [ + "read", + "write", + "deny" + ] + }, + "FileSystemPath": { + "oneOf": [ + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "path" + ], + "title": "PathFileSystemPathType" + } + }, + "title": "PathFileSystemPath" + }, + { + "type": "object", + "required": [ + "pattern", + "type" + ], + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType" + } + }, + "title": "GlobPatternFileSystemPath" + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "title": "SpecialFileSystemPath" + } + ] + }, + "FileSystemSandboxEntry": { + "type": "object", + "required": [ + "access", + "path" + ], + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + } + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "root" + ] + } + }, + "title": "RootFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "minimal" + ] + } + }, + "title": "MinimalFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "project_roots" + ] + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + }, + "title": "KindFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "tmpdir" + ] + } + }, + "title": "TmpdirFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "slash_tmp" + ] + } + }, + "title": "SlashTmpFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "unknown" + ] + }, + "path": { + "type": "string" + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "RequestPermissionProfile": { + "type": "object", + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/PermissionsRequestApprovalResponse.gen.json b/codexapp/internal/protocol/schema/PermissionsRequestApprovalResponse.gen.json new file mode 100644 index 0000000..3b946cf --- /dev/null +++ b/codexapp/internal/protocol/schema/PermissionsRequestApprovalResponse.gen.json @@ -0,0 +1,322 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionsRequestApprovalResponse", + "type": "object", + "required": [ + "permissions" + ], + "properties": { + "permissions": { + "$ref": "#/definitions/GrantedPermissionProfile" + }, + "scope": { + "default": "turn", + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ] + }, + "strictAutoReview": { + "description": "Review every subsequent command in this turn before normal sandboxed execution.", + "type": [ + "boolean", + "null" + ] + } + }, + "definitions": { + "AdditionalFileSystemPermissions": { + "type": "object", + "properties": { + "entries": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + } + }, + "globScanMaxDepth": { + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 1.0 + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + } + } + }, + "AdditionalNetworkPermissions": { + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "FileSystemAccessMode": { + "type": "string", + "enum": [ + "read", + "write", + "deny" + ] + }, + "FileSystemPath": { + "oneOf": [ + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "path" + ], + "title": "PathFileSystemPathType" + } + }, + "title": "PathFileSystemPath" + }, + { + "type": "object", + "required": [ + "pattern", + "type" + ], + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType" + } + }, + "title": "GlobPatternFileSystemPath" + }, + { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "title": "SpecialFileSystemPath" + } + ] + }, + "FileSystemSandboxEntry": { + "type": "object", + "required": [ + "access", + "path" + ], + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + } + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "root" + ] + } + }, + "title": "RootFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "minimal" + ] + } + }, + "title": "MinimalFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "project_roots" + ] + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + }, + "title": "KindFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "tmpdir" + ] + } + }, + "title": "TmpdirFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "slash_tmp" + ] + } + }, + "title": "SlashTmpFileSystemSpecialPath" + }, + { + "type": "object", + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "unknown" + ] + }, + "path": { + "type": "string" + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "GrantedPermissionProfile": { + "type": "object", + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + } + }, + "LegacyAppPathString": { + "type": "string" + }, + "PermissionGrantScope": { + "type": "string", + "enum": [ + "turn", + "session" + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/ToolRequestUserInputParams.gen.json b/codexapp/internal/protocol/schema/ToolRequestUserInputParams.gen.json new file mode 100644 index 0000000..0874e6a --- /dev/null +++ b/codexapp/internal/protocol/schema/ToolRequestUserInputParams.gen.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ToolRequestUserInputParams", + "description": "EXPERIMENTAL. Params sent with a request_user_input event.", + "type": "object", + "required": [ + "itemId", + "questions", + "threadId", + "turnId" + ], + "properties": { + "autoResolutionMs": { + "default": null, + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "itemId": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/definitions/ToolRequestUserInputQuestion" + } + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "definitions": { + "ToolRequestUserInputOption": { + "description": "EXPERIMENTAL. Defines a single selectable option for request_user_input.", + "type": "object", + "required": [ + "description", + "label" + ], + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "ToolRequestUserInputQuestion": { + "description": "EXPERIMENTAL. Represents one request_user_input question and its required options.", + "type": "object", + "required": [ + "header", + "id", + "question" + ], + "properties": { + "header": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isOther": { + "default": false, + "type": "boolean" + }, + "isSecret": { + "default": false, + "type": "boolean" + }, + "options": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ToolRequestUserInputOption" + } + }, + "question": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/ToolRequestUserInputResponse.gen.json b/codexapp/internal/protocol/schema/ToolRequestUserInputResponse.gen.json new file mode 100644 index 0000000..73d87dd --- /dev/null +++ b/codexapp/internal/protocol/schema/ToolRequestUserInputResponse.gen.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ToolRequestUserInputResponse", + "description": "EXPERIMENTAL. Response payload mapping question ids to answers.", + "type": "object", + "required": [ + "answers" + ], + "properties": { + "answers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/ToolRequestUserInputAnswer" + } + } + }, + "definitions": { + "ToolRequestUserInputAnswer": { + "description": "EXPERIMENTAL. Captures a user's answer to a request_user_input question.", + "type": "object", + "required": [ + "answers" + ], + "properties": { + "answers": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v1/InitializeParams.gen.json b/codexapp/internal/protocol/schema/v1/InitializeParams.gen.json new file mode 100644 index 0000000..5d2641b --- /dev/null +++ b/codexapp/internal/protocol/schema/v1/InitializeParams.gen.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InitializeParams", + "type": "object", + "required": [ + "clientInfo" + ], + "properties": { + "capabilities": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeCapabilities" + }, + { + "type": "null" + } + ] + }, + "clientInfo": { + "$ref": "#/definitions/ClientInfo" + } + }, + "definitions": { + "ClientInfo": { + "type": "object", + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + } + }, + "InitializeCapabilities": { + "description": "Client-declared capabilities negotiated during initialize.", + "type": "object", + "properties": { + "experimentalApi": { + "description": "Opt into receiving experimental API methods and fields.", + "default": false, + "type": "boolean" + }, + "mcpServerOpenaiFormElicitation": { + "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", + "type": "boolean" + }, + "optOutNotificationMethods": { + "description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "requestAttestation": { + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "default": false, + "type": "boolean" + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v1/InitializeResponse.gen.json b/codexapp/internal/protocol/schema/v1/InitializeResponse.gen.json new file mode 100644 index 0000000..462c818 --- /dev/null +++ b/codexapp/internal/protocol/schema/v1/InitializeResponse.gen.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InitializeResponse", + "type": "object", + "required": [ + "codexHome", + "platformFamily", + "platformOs", + "userAgent" + ], + "properties": { + "codexHome": { + "description": "Absolute path to the server's $CODEX_HOME directory.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "platformFamily": { + "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", + "type": "string" + }, + "platformOs": { + "description": "Operating system for the running app-server target, for example `\"macos\"`, `\"linux\"`, or `\"windows\"`.", + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/AgentMessageDeltaNotification.gen.json b/codexapp/internal/protocol/schema/v2/AgentMessageDeltaNotification.gen.json new file mode 100644 index 0000000..b286877 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/AgentMessageDeltaNotification.gen.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AgentMessageDeltaNotification", + "type": "object", + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/CommandExecutionOutputDeltaNotification.gen.json b/codexapp/internal/protocol/schema/v2/CommandExecutionOutputDeltaNotification.gen.json new file mode 100644 index 0000000..5aa9095 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/CommandExecutionOutputDeltaNotification.gen.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommandExecutionOutputDeltaNotification", + "type": "object", + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ErrorNotification.gen.json b/codexapp/internal/protocol/schema/v2/ErrorNotification.gen.json new file mode 100644 index 0000000..3f14b35 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ErrorNotification.gen.json @@ -0,0 +1,200 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ErrorNotification", + "type": "object", + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "properties": { + "error": { + "$ref": "#/definitions/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "type": "boolean" + } + }, + "definitions": { + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": [ + "httpConnectionFailed" + ], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": [ + "responseStreamConnectionFailed" + ], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": [ + "responseStreamDisconnected" + ], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": [ + "responseTooManyFailedAttempts" + ], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": [ + "activeTurnNotSteerable" + ], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": [ + "turnKind" + ], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": [ + "review", + "compact" + ] + }, + "TurnError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ItemCompletedNotification.gen.json b/codexapp/internal/protocol/schema/v2/ItemCompletedNotification.gen.json new file mode 100644 index 0000000..cbd6150 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ItemCompletedNotification.gen.json @@ -0,0 +1,1633 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ItemCompletedNotification", + "type": "object", + "required": [ + "completedAtMs", + "item", + "threadId", + "turnId" + ], + "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "type": "integer", + "format": "int64" + }, + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ItemStartedNotification.gen.json b/codexapp/internal/protocol/schema/v2/ItemStartedNotification.gen.json new file mode 100644 index 0000000..7bfadc0 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ItemStartedNotification.gen.json @@ -0,0 +1,1633 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ItemStartedNotification", + "type": "object", + "required": [ + "item", + "startedAtMs", + "threadId", + "turnId" + ], + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "type": "integer", + "format": "int64" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ModelListParams.gen.json b/codexapp/internal/protocol/schema/v2/ModelListParams.gen.json new file mode 100644 index 0000000..cd7bb25 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ModelListParams.gen.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ModelListParams", + "type": "object", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ModelListResponse.gen.json b/codexapp/internal/protocol/schema/v2/ModelListResponse.gen.json new file mode 100644 index 0000000..ecb7253 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ModelListResponse.gen.json @@ -0,0 +1,235 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ModelListResponse", + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/Model" + } + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "InputModality": { + "description": "Canonical user-input modality tags advertised by a model.", + "oneOf": [ + { + "description": "Plain text turns and tool payloads.", + "type": "string", + "enum": [ + "text" + ] + }, + { + "description": "Image attachments included in user turns.", + "type": "string", + "enum": [ + "image" + ] + }, + { + "description": "Audio attachments included in user turns.", + "type": "string", + "enum": [ + "audio" + ] + } + ] + }, + "Model": { + "type": "object", + "required": [ + "defaultReasoningEffort", + "description", + "displayName", + "hidden", + "id", + "isDefault", + "model", + "supportedReasoningEfforts" + ], + "properties": { + "additionalSpeedTiers": { + "description": "Deprecated: use `serviceTiers` instead.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "availabilityNux": { + "anyOf": [ + { + "$ref": "#/definitions/ModelAvailabilityNux" + }, + { + "type": "null" + } + ] + }, + "defaultReasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "defaultServiceTier": { + "description": "Catalog default service tier id for this model, when one is configured.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "inputModalities": { + "default": [ + "text", + "image" + ], + "type": "array", + "items": { + "$ref": "#/definitions/InputModality" + } + }, + "isDefault": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "serviceTiers": { + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/ModelServiceTier" + } + }, + "supportedReasoningEfforts": { + "type": "array", + "items": { + "$ref": "#/definitions/ReasoningEffortOption" + } + }, + "supportsPersonality": { + "default": false, + "type": "boolean" + }, + "upgrade": { + "type": [ + "string", + "null" + ] + }, + "upgradeInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ModelUpgradeInfo" + }, + { + "type": "null" + } + ] + } + } + }, + "ModelAvailabilityNux": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "ModelServiceTier": { + "type": "object", + "required": [ + "description", + "id", + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "ModelUpgradeInfo": { + "type": "object", + "required": [ + "model" + ], + "properties": { + "migrationMarkdown": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "modelLink": { + "type": [ + "string", + "null" + ] + }, + "upgradeCopy": { + "type": [ + "string", + "null" + ] + } + } + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "ReasoningEffortOption": { + "type": "object", + "required": [ + "description", + "reasoningEffort" + ], + "properties": { + "description": { + "type": "string" + }, + "reasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/PermissionProfileListParams.gen.json b/codexapp/internal/protocol/schema/v2/PermissionProfileListParams.gen.json new file mode 100644 index 0000000..18d3e65 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/PermissionProfileListParams.gen.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionProfileListParams", + "type": "object", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/PermissionProfileListResponse.gen.json b/codexapp/internal/protocol/schema/v2/PermissionProfileListResponse.gen.json new file mode 100644 index 0000000..19eba4c --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/PermissionProfileListResponse.gen.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PermissionProfileListResponse", + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/PermissionProfileSummary" + } + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "PermissionProfileSummary": { + "type": "object", + "required": [ + "allowed", + "id" + ], + "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/PlanDeltaNotification.gen.json b/codexapp/internal/protocol/schema/v2/PlanDeltaNotification.gen.json new file mode 100644 index 0000000..baf0c8e --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/PlanDeltaNotification.gen.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PlanDeltaNotification", + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", + "type": "object", + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ReasoningSummaryPartAddedNotification.gen.json b/codexapp/internal/protocol/schema/v2/ReasoningSummaryPartAddedNotification.gen.json new file mode 100644 index 0000000..b9e449e --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ReasoningSummaryPartAddedNotification.gen.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ReasoningSummaryPartAddedNotification", + "type": "object", + "required": [ + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "properties": { + "itemId": { + "type": "string" + }, + "summaryIndex": { + "type": "integer", + "format": "int64" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ReasoningSummaryTextDeltaNotification.gen.json b/codexapp/internal/protocol/schema/v2/ReasoningSummaryTextDeltaNotification.gen.json new file mode 100644 index 0000000..419c3a4 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ReasoningSummaryTextDeltaNotification.gen.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ReasoningSummaryTextDeltaNotification", + "type": "object", + "required": [ + "delta", + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "summaryIndex": { + "type": "integer", + "format": "int64" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ReasoningTextDeltaNotification.gen.json b/codexapp/internal/protocol/schema/v2/ReasoningTextDeltaNotification.gen.json new file mode 100644 index 0000000..d68ad40 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ReasoningTextDeltaNotification.gen.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ReasoningTextDeltaNotification", + "type": "object", + "required": [ + "contentIndex", + "delta", + "itemId", + "threadId", + "turnId" + ], + "properties": { + "contentIndex": { + "type": "integer", + "format": "int64" + }, + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ServerRequestResolvedNotification.gen.json b/codexapp/internal/protocol/schema/v2/ServerRequestResolvedNotification.gen.json new file mode 100644 index 0000000..f0f21d7 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ServerRequestResolvedNotification.gen.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ServerRequestResolvedNotification", + "type": "object", + "required": [ + "requestId", + "threadId" + ], + "properties": { + "requestId": { + "$ref": "#/definitions/RequestId" + }, + "threadId": { + "type": "string" + } + }, + "definitions": { + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ThreadReadParams.gen.json b/codexapp/internal/protocol/schema/v2/ThreadReadParams.gen.json new file mode 100644 index 0000000..8f37027 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ThreadReadParams.gen.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadReadParams", + "type": "object", + "required": [ + "threadId" + ], + "properties": { + "includeTurns": { + "description": "When true, include turns and their items from rollout history.", + "type": "boolean" + }, + "threadId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ThreadReadResponse.gen.json b/codexapp/internal/protocol/schema/v2/ThreadReadResponse.gen.json new file mode 100644 index 0000000..1693172 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ThreadReadResponse.gen.json @@ -0,0 +1,2323 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadReadResponse", + "type": "object", + "required": [ + "thread" + ], + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AgentPath": { + "type": "string" + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": [ + "httpConnectionFailed" + ], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": [ + "responseStreamConnectionFailed" + ], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": [ + "responseStreamDisconnected" + ], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": [ + "responseTooManyFailedAttempts" + ], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": [ + "activeTurnNotSteerable" + ], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": [ + "turnKind" + ], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "GitInfo": { + "type": "object", + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": [ + "review", + "compact" + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SessionSource": { + "oneOf": [ + { + "type": "string", + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ] + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomSessionSource" + }, + { + "type": "object", + "required": [ + "subAgent" + ], + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "additionalProperties": false, + "title": "SubAgentSessionSource" + } + ] + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "SubAgentSource": { + "oneOf": [ + { + "type": "string", + "enum": [ + "review", + "compact", + "memory_consolidation" + ] + }, + { + "type": "object", + "required": [ + "thread_spawn" + ], + "properties": { + "thread_spawn": { + "type": "object", + "required": [ + "depth", + "parent_thread_id" + ], + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "type": "integer", + "format": "int32" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + } + } + }, + "additionalProperties": false, + "title": "ThreadSpawnSubAgentSource" + }, + { + "type": "object", + "required": [ + "other" + ], + "properties": { + "other": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "OtherSubAgentSource" + } + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "Thread": { + "type": "object", + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": [ + "boolean", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "type": "integer", + "format": "int64" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "description": "Optional implementation-specific thread data.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ] + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, + "gitInfo": { + "description": "Optional Git metadata captured when the thread was created.", + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ] + }, + "historyMode": { + "description": "Persisted thread history contract selected when this thread was created.", + "default": "legacy", + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ] + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "isPinned": { + "description": "Whether the thread has been pinned by the user.", + "default": false, + "type": "boolean" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).", + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ] + }, + "status": { + "description": "Current runtime status for the thread.", + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ] + }, + "threadSource": { + "description": "Optional analytics source classification for this thread.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ] + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "type": "array", + "items": { + "$ref": "#/definitions/Turn" + } + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "type": "integer", + "format": "int64" + } + } + }, + "ThreadActiveFlag": { + "type": "string", + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ] + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadHistoryMode": { + "type": "string", + "enum": [ + "legacy", + "paginated" + ] + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType" + } + }, + "title": "NotLoadedThreadStatus" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType" + } + }, + "title": "IdleThreadStatus" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType" + } + }, + "title": "SystemErrorThreadStatus" + }, + { + "type": "object", + "required": [ + "activeFlags", + "type" + ], + "properties": { + "activeFlags": { + "type": "array", + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + } + }, + "type": { + "type": "string", + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType" + } + }, + "title": "ActiveThreadStatus" + } + ] + }, + "Turn": { + "type": "object", + "required": [ + "id", + "items", + "status" + ], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": [ + "notLoaded" + ] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": [ + "summary" + ] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": [ + "full" + ] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ThreadResumeParams.gen.json b/codexapp/internal/protocol/schema/v2/ThreadResumeParams.gen.json new file mode 100644 index 0000000..5142013 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ThreadResumeParams.gen.json @@ -0,0 +1,1516 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadResumeParams", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", + "type": "object", + "required": [ + "threadId" + ], + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvalsReviewer": { + "description": "Override where approval requests are routed for review on this thread and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "excludeTurns": { + "description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.", + "type": "boolean" + }, + "history": { + "description": "[UNSTABLE] FOR CODEX CLOUD - DO NOT USE. If specified, the thread will be resumed with the provided history instead of loaded from disk.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ResponseItem" + } + }, + "initialTurnsPage": { + "description": "When present, include a `thread/turns/list` page in the resume response so clients can bootstrap recent turns without a second request.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadResumeInitialTurnsPageParams" + }, + { + "type": "null" + } + ] + }, + "model": { + "description": "Configuration overrides for the resumed thread, if any.", + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Specify the rollout path to resume from. If specified for a non-running thread, the thread_id param will be ignored. If thread_id identifies a running thread, the path must match the active rollout path.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "permissions": { + "description": "Named profile id for the resumed thread. Cannot be combined with `sandbox`.", + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "description": "Replace the thread's runtime workspace roots. Paths must be absolute.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AgentMessageInputContent": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "input_text" + ], + "title": "InputTextAgentMessageInputContentType" + } + }, + "title": "InputTextAgentMessageInputContent" + }, + { + "type": "object", + "required": [ + "encrypted_content", + "type" + ], + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType" + } + }, + "title": "EncryptedContentAgentMessageInputContent" + } + ] + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "type": "string", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ] + }, + "AskForApproval": { + "oneOf": [ + { + "type": "string", + "enum": [ + "untrusted", + "on-request", + "never" + ] + }, + { + "type": "object", + "required": [ + "granular" + ], + "properties": { + "granular": { + "type": "object", + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + } + } + }, + "additionalProperties": false, + "title": "GranularAskForApproval" + } + ] + }, + "ContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "input_text" + ], + "title": "InputTextContentItemType" + } + }, + "title": "InputTextContentItem" + }, + { + "type": "object", + "required": [ + "image_url", + "type" + ], + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "input_image" + ], + "title": "InputImageContentItemType" + } + }, + "title": "InputImageContentItem" + }, + { + "type": "object", + "required": [ + "audio_url", + "type" + ], + "properties": { + "audio_url": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "input_audio" + ], + "title": "InputAudioContentItemType" + } + }, + "title": "InputAudioContentItem" + }, + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "output_text" + ], + "title": "OutputTextContentItemType" + } + }, + "title": "OutputTextContentItem" + } + ] + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + } + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "input_text" + ], + "title": "InputTextFunctionCallOutputContentItemType" + } + }, + "title": "InputTextFunctionCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "image_url", + "type" + ], + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "input_image" + ], + "title": "InputImageFunctionCallOutputContentItemType" + } + }, + "title": "InputImageFunctionCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audio_url", + "type" + ], + "properties": { + "audio_url": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "input_audio" + ], + "title": "InputAudioFunctionCallOutputContentItemType" + } + }, + "title": "InputAudioFunctionCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "encrypted_content", + "type" + ], + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType" + } + }, + "title": "EncryptedContentFunctionCallOutputContentItem" + } + ] + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "type": "object", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + } + }, + "LocalShellAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "timeout_ms": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "type": { + "type": "string", + "enum": [ + "exec" + ], + "title": "ExecLocalShellActionType" + }, + "user": { + "type": [ + "string", + "null" + ] + }, + "working_directory": { + "type": [ + "string", + "null" + ] + } + }, + "title": "ExecLocalShellAction" + } + ] + }, + "LocalShellStatus": { + "type": "string", + "enum": [ + "completed", + "in_progress", + "incomplete" + ] + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "Personality": { + "type": "string", + "enum": [ + "none", + "friendly", + "pragmatic" + ] + }, + "ReasoningItemContent": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "reasoning_text" + ], + "title": "ReasoningTextReasoningItemContentType" + } + }, + "title": "ReasoningTextReasoningItemContent" + }, + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextReasoningItemContentType" + } + }, + "title": "TextReasoningItemContent" + } + ] + }, + "ReasoningItemReasoningSummary": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "summary_text" + ], + "title": "SummaryTextReasoningItemReasoningSummaryType" + } + }, + "title": "SummaryTextReasoningItemReasoningSummary" + } + ] + }, + "ResponseItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "role", + "type" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentItem" + } + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "message" + ], + "title": "MessageResponseItemType" + } + }, + "title": "MessageResponseItem" + }, + { + "type": "object", + "required": [ + "author", + "content", + "recipient", + "type" + ], + "properties": { + "author": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + } + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "recipient": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType" + } + }, + "title": "AgentMessageResponseItem" + }, + { + "type": "object", + "required": [ + "summary", + "type" + ], + "properties": { + "content": { + "default": null, + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ReasoningItemContent" + } + }, + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "summary": { + "type": "array", + "items": { + "$ref": "#/definitions/ReasoningItemReasoningSummary" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningResponseItemType" + } + }, + "title": "ReasoningResponseItem" + }, + { + "type": "object", + "required": [ + "action", + "status", + "type" + ], + "properties": { + "action": { + "$ref": "#/definitions/LocalShellAction" + }, + "call_id": { + "description": "Set when using the Responses API.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/LocalShellStatus" + }, + "type": { + "type": "string", + "enum": [ + "local_shell_call" + ], + "title": "LocalShellCallResponseItemType" + } + }, + "title": "LocalShellCallResponseItem" + }, + { + "type": "object", + "required": [ + "arguments", + "call_id", + "name", + "type" + ], + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "function_call" + ], + "title": "FunctionCallResponseItemType" + } + }, + "title": "FunctionCallResponseItem" + }, + { + "type": "object", + "required": [ + "arguments", + "execution", + "type" + ], + "properties": { + "arguments": true, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "tool_search_call" + ], + "title": "ToolSearchCallResponseItemType" + } + }, + "title": "ToolSearchCallResponseItem" + }, + { + "type": "object", + "required": [ + "call_id", + "output", + "type" + ], + "properties": { + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "type": "string", + "enum": [ + "function_call_output" + ], + "title": "FunctionCallOutputResponseItemType" + } + }, + "title": "FunctionCallOutputResponseItem" + }, + { + "type": "object", + "required": [ + "call_id", + "input", + "name", + "type" + ], + "properties": { + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "input": { + "type": "string" + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "custom_tool_call" + ], + "title": "CustomToolCallResponseItemType" + } + }, + "title": "CustomToolCallResponseItem" + }, + { + "type": "object", + "required": [ + "call_id", + "output", + "type" + ], + "properties": { + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "type": "string", + "enum": [ + "custom_tool_call_output" + ], + "title": "CustomToolCallOutputResponseItemType" + } + }, + "title": "CustomToolCallOutputResponseItem" + }, + { + "type": "object", + "required": [ + "execution", + "status", + "tools", + "type" + ], + "properties": { + "call_id": { + "type": [ + "string", + "null" + ] + }, + "execution": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "tools": { + "type": "array", + "items": true + }, + "type": { + "type": "string", + "enum": [ + "tool_search_output" + ], + "title": "ToolSearchOutputResponseItemType" + } + }, + "title": "ToolSearchOutputResponseItem" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/ResponsesApiWebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "web_search_call" + ], + "title": "WebSearchCallResponseItemType" + } + }, + "title": "WebSearchCallResponseItem" + }, + { + "type": "object", + "required": [ + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "result": { + "type": "string" + }, + "revised_prompt": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "image_generation_call" + ], + "title": "ImageGenerationCallResponseItemType" + } + }, + "title": "ImageGenerationCallResponseItem" + }, + { + "type": "object", + "required": [ + "encrypted_content", + "type" + ], + "properties": { + "encrypted_content": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "compaction" + ], + "title": "CompactionResponseItemType" + } + }, + "title": "CompactionResponseItem" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType" + } + }, + "title": "CompactionTriggerResponseItem" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": [ + "string", + "null" + ] + }, + "internal_chat_message_metadata_passthrough": { + "anyOf": [ + { + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType" + } + }, + "title": "ContextCompactionResponseItem" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherResponseItemType" + } + }, + "title": "OtherResponseItem" + } + ] + }, + "ResponsesApiWebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchResponsesApiWebSearchActionType" + } + }, + "title": "SearchResponsesApiWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "open_page" + ], + "title": "OpenPageResponsesApiWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageResponsesApiWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "find_in_page" + ], + "title": "FindInPageResponsesApiWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageResponsesApiWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherResponsesApiWebSearchActionType" + } + }, + "title": "OtherResponsesApiWebSearchAction" + } + ] + }, + "SandboxMode": { + "type": "string", + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ] + }, + "SortDirection": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "ThreadResumeInitialTurnsPageParams": { + "type": "object", + "properties": { + "itemsView": { + "description": "How much item detail to include for each returned turn; defaults to summary.", + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ] + }, + "limit": { + "description": "Optional turn page size.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "sortDirection": { + "description": "Optional turn pagination direction; defaults to descending.", + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ] + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": [ + "notLoaded" + ] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": [ + "summary" + ] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": [ + "full" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ThreadResumeResponse.gen.json b/codexapp/internal/protocol/schema/v2/ThreadResumeResponse.gen.json new file mode 100644 index 0000000..1af7ddd --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ThreadResumeResponse.gen.json @@ -0,0 +1,2673 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadResumeResponse", + "type": "object", + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "properties": { + "activePermissionProfile": { + "description": "Named or implicit built-in profile that produced the active permissions, when known.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "description": "Reviewer currently used for approval requests on this thread.", + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ] + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "initialTurnsPage": { + "description": "`thread/turns/list` page returned when requested by `initialTurnsPage`.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/TurnsPage" + }, + { + "type": "null" + } + ] + }, + "instructionSources": { + "description": "Environment-native paths to instruction source files currently loaded for this thread.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + }, + "itemsBackwardsCursor": { + "description": "Opaque head cursor for hydrating paginated items backwards.\n\nPass this as `cursor` to `thread/items/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head item.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "multiAgentMode": { + "description": "@deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior.", + "default": "explicitRequestOnly", + "allOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + } + ] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "description": "Thread-scoped runtime workspace roots used to materialize `:workspace_roots`.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "sandbox": { + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.", + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + }, + "turnsBackwardsCursor": { + "description": "Opaque head cursor for hydrating paginated turns backwards.\n\nPass this as `cursor` to `thread/turns/list` with `sortDirection: \"desc\"`. The first page includes the cursor's head turn.", + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "extends": { + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + } + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "type": "string", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ] + }, + "AskForApproval": { + "oneOf": [ + { + "type": "string", + "enum": [ + "untrusted", + "on-request", + "never" + ] + }, + { + "type": "object", + "required": [ + "granular" + ], + "properties": { + "granular": { + "type": "object", + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + } + } + }, + "additionalProperties": false, + "title": "GranularAskForApproval" + } + ] + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": [ + "httpConnectionFailed" + ], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": [ + "responseStreamConnectionFailed" + ], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": [ + "responseStreamDisconnected" + ], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": [ + "responseTooManyFailedAttempts" + ], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": [ + "activeTurnNotSteerable" + ], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": [ + "turnKind" + ], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "GitInfo": { + "type": "object", + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "type": "string", + "enum": [ + "explicitRequestOnly", + "proactive" + ] + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomMultiAgentMode" + } + ] + }, + "NetworkAccess": { + "type": "string", + "enum": [ + "restricted", + "enabled" + ] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": [ + "review", + "compact" + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SandboxPolicy": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType" + } + }, + "title": "DangerFullAccessSandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType" + } + }, + "title": "ReadOnlySandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "networkAccess": { + "default": "restricted", + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType" + } + }, + "title": "ExternalSandboxSandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType" + }, + "writableRoots": { + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + } + }, + "title": "WorkspaceWriteSandboxPolicy" + } + ] + }, + "SessionSource": { + "oneOf": [ + { + "type": "string", + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ] + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomSessionSource" + }, + { + "type": "object", + "required": [ + "subAgent" + ], + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "additionalProperties": false, + "title": "SubAgentSessionSource" + } + ] + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "SubAgentSource": { + "oneOf": [ + { + "type": "string", + "enum": [ + "review", + "compact", + "memory_consolidation" + ] + }, + { + "type": "object", + "required": [ + "thread_spawn" + ], + "properties": { + "thread_spawn": { + "type": "object", + "required": [ + "depth", + "parent_thread_id" + ], + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "type": "integer", + "format": "int32" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + } + } + }, + "additionalProperties": false, + "title": "ThreadSpawnSubAgentSource" + }, + { + "type": "object", + "required": [ + "other" + ], + "properties": { + "other": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "OtherSubAgentSource" + } + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "Thread": { + "type": "object", + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": [ + "boolean", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "type": "integer", + "format": "int64" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "description": "Optional implementation-specific thread data.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ] + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, + "gitInfo": { + "description": "Optional Git metadata captured when the thread was created.", + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ] + }, + "historyMode": { + "description": "Persisted thread history contract selected when this thread was created.", + "default": "legacy", + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ] + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "isPinned": { + "description": "Whether the thread has been pinned by the user.", + "default": false, + "type": "boolean" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).", + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ] + }, + "status": { + "description": "Current runtime status for the thread.", + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ] + }, + "threadSource": { + "description": "Optional analytics source classification for this thread.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ] + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "type": "array", + "items": { + "$ref": "#/definitions/Turn" + } + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "type": "integer", + "format": "int64" + } + } + }, + "ThreadActiveFlag": { + "type": "string", + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ] + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadHistoryMode": { + "type": "string", + "enum": [ + "legacy", + "paginated" + ] + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType" + } + }, + "title": "NotLoadedThreadStatus" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType" + } + }, + "title": "IdleThreadStatus" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType" + } + }, + "title": "SystemErrorThreadStatus" + }, + { + "type": "object", + "required": [ + "activeFlags", + "type" + ], + "properties": { + "activeFlags": { + "type": "array", + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + } + }, + "type": { + "type": "string", + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType" + } + }, + "title": "ActiveThreadStatus" + } + ] + }, + "Turn": { + "type": "object", + "required": [ + "id", + "items", + "status" + ], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": [ + "notLoaded" + ] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": [ + "summary" + ] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": [ + "full" + ] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ] + }, + "TurnsPage": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/Turn" + } + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + } + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ThreadStartParams.gen.json b/codexapp/internal/protocol/schema/v2/ThreadStartParams.gen.json new file mode 100644 index 0000000..7602466 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ThreadStartParams.gen.json @@ -0,0 +1,508 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadStartParams", + "type": "object", + "properties": { + "allowProviderModelFallback": { + "description": "Allow a provider with an authoritative static model catalog to replace an unavailable requested model with its default.", + "type": "boolean" + }, + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvalsReviewer": { + "description": "Override where approval requests are routed for review on this thread and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "dynamicTools": { + "default": null, + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolSpec" + } + }, + "environments": { + "description": "Optional sticky environments for this thread.\n\nOmitted selects the default environment when environment access is enabled. Empty disables environment access for turns that do not provide a turn override. Non-empty selects the first environment as the current turn environment.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/TurnEnvironmentParams" + } + }, + "ephemeral": { + "type": [ + "boolean", + "null" + ] + }, + "experimentalRawEvents": { + "description": "If true, opt into emitting raw Responses API items on the event stream. This is for internal use only (e.g. Codex Cloud).", + "type": "boolean" + }, + "historyMode": { + "description": "Persisted thread history contract to use for this new thread.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + }, + { + "type": "null" + } + ] + }, + "mockExperimentalField": { + "description": "Test-only experimental field used to validate experimental gating and schema filtering behavior in a stable way.", + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "multiAgentMode": { + "description": "@deprecated Ignored. Use Ultra reasoning effort for proactive multi-agent behavior.", + "anyOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + }, + { + "type": "null" + } + ] + }, + "permissions": { + "description": "Named profile id for this thread. Cannot be combined with `sandbox`.", + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "description": "Replace the thread's runtime workspace roots. Paths must be absolute.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "selectedCapabilityRoots": { + "description": "Capability roots selected for this thread by the hosting platform.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/SelectedCapabilityRoot" + } + }, + "serviceName": { + "type": [ + "string", + "null" + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadStartSource" + }, + { + "type": "null" + } + ] + }, + "threadSource": { + "description": "Optional client-supplied analytics source classification for this thread.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ] + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "type": "string", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ] + }, + "AskForApproval": { + "oneOf": [ + { + "type": "string", + "enum": [ + "untrusted", + "on-request", + "never" + ] + }, + { + "type": "object", + "required": [ + "granular" + ], + "properties": { + "granular": { + "type": "object", + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + } + } + }, + "additionalProperties": false, + "title": "GranularAskForApproval" + } + ] + }, + "CapabilityRootLocation": { + "description": "Location used to resolve a selected capability root.", + "oneOf": [ + { + "description": "A path owned by an execution environment.", + "type": "object", + "required": [ + "environmentId", + "path", + "type" + ], + "properties": { + "environmentId": { + "type": "string" + }, + "path": { + "description": "Absolute path for the root in the selected environment.", + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "environment" + ], + "title": "EnvironmentCapabilityRootLocationType" + } + }, + "title": "EnvironmentCapabilityRootLocation" + } + ] + }, + "DynamicToolNamespaceTool": { + "oneOf": [ + { + "type": "object", + "required": [ + "description", + "inputSchema", + "name", + "type" + ], + "properties": { + "deferLoading": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "inputSchema": true, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "function" + ], + "title": "FunctionDynamicToolNamespaceToolType" + } + }, + "title": "FunctionDynamicToolNamespaceTool" + } + ] + }, + "DynamicToolSpec": { + "oneOf": [ + { + "type": "object", + "required": [ + "description", + "inputSchema", + "name", + "type" + ], + "properties": { + "deferLoading": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "inputSchema": true, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "function" + ], + "title": "FunctionDynamicToolSpecType" + } + }, + "title": "FunctionDynamicToolSpec" + }, + { + "type": "object", + "required": [ + "description", + "name", + "tools", + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/definitions/DynamicToolNamespaceTool" + } + }, + "type": { + "type": "string", + "enum": [ + "namespace" + ], + "title": "NamespaceDynamicToolSpecType" + } + }, + "title": "NamespaceDynamicToolSpec" + } + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "type": "string", + "enum": [ + "explicitRequestOnly", + "proactive" + ] + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomMultiAgentMode" + } + ] + }, + "Personality": { + "type": "string", + "enum": [ + "none", + "friendly", + "pragmatic" + ] + }, + "SandboxMode": { + "type": "string", + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ] + }, + "SelectedCapabilityRoot": { + "description": "A user-selected root that can expose one or more runtime capabilities.", + "type": "object", + "required": [ + "id", + "location" + ], + "properties": { + "id": { + "description": "Stable identifier supplied by the capability selection platform.", + "type": "string" + }, + "location": { + "description": "Where the selected root can be resolved.", + "allOf": [ + { + "$ref": "#/definitions/CapabilityRootLocation" + } + ] + } + } + }, + "ThreadHistoryMode": { + "type": "string", + "enum": [ + "legacy", + "paginated" + ] + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStartSource": { + "type": "string", + "enum": [ + "startup", + "clear" + ] + }, + "TurnEnvironmentParams": { + "type": "object", + "required": [ + "cwd", + "environmentId" + ], + "properties": { + "cwd": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "environmentId": { + "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ThreadStartResponse.gen.json b/codexapp/internal/protocol/schema/v2/ThreadStartResponse.gen.json new file mode 100644 index 0000000..a83928d --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ThreadStartResponse.gen.json @@ -0,0 +1,2619 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadStartResponse", + "type": "object", + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "properties": { + "activePermissionProfile": { + "description": "Named or implicit built-in profile that produced the active permissions, when known.", + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "description": "Reviewer currently used for approval requests on this thread.", + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ] + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "description": "Environment-native paths to instruction source files currently loaded for this thread.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "multiAgentMode": { + "description": "@deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior.", + "default": "explicitRequestOnly", + "allOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + } + ] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "description": "Thread-scoped runtime workspace roots used to materialize `:workspace_roots`.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "sandbox": { + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance.", + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ] + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "extends": { + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + } + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "type": "string", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ] + }, + "AskForApproval": { + "oneOf": [ + { + "type": "string", + "enum": [ + "untrusted", + "on-request", + "never" + ] + }, + { + "type": "object", + "required": [ + "granular" + ], + "properties": { + "granular": { + "type": "object", + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + } + } + }, + "additionalProperties": false, + "title": "GranularAskForApproval" + } + ] + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": [ + "httpConnectionFailed" + ], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": [ + "responseStreamConnectionFailed" + ], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": [ + "responseStreamDisconnected" + ], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": [ + "responseTooManyFailedAttempts" + ], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": [ + "activeTurnNotSteerable" + ], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": [ + "turnKind" + ], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "GitInfo": { + "type": "object", + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "type": "string", + "enum": [ + "explicitRequestOnly", + "proactive" + ] + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomMultiAgentMode" + } + ] + }, + "NetworkAccess": { + "type": "string", + "enum": [ + "restricted", + "enabled" + ] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": [ + "review", + "compact" + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SandboxPolicy": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType" + } + }, + "title": "DangerFullAccessSandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType" + } + }, + "title": "ReadOnlySandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "networkAccess": { + "default": "restricted", + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType" + } + }, + "title": "ExternalSandboxSandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType" + }, + "writableRoots": { + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + } + }, + "title": "WorkspaceWriteSandboxPolicy" + } + ] + }, + "SessionSource": { + "oneOf": [ + { + "type": "string", + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ] + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomSessionSource" + }, + { + "type": "object", + "required": [ + "subAgent" + ], + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "additionalProperties": false, + "title": "SubAgentSessionSource" + } + ] + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "SubAgentSource": { + "oneOf": [ + { + "type": "string", + "enum": [ + "review", + "compact", + "memory_consolidation" + ] + }, + { + "type": "object", + "required": [ + "thread_spawn" + ], + "properties": { + "thread_spawn": { + "type": "object", + "required": [ + "depth", + "parent_thread_id" + ], + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_path": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "type": "integer", + "format": "int32" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + } + } + }, + "additionalProperties": false, + "title": "ThreadSpawnSubAgentSource" + }, + { + "type": "object", + "required": [ + "other" + ], + "properties": { + "other": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "OtherSubAgentSource" + } + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "Thread": { + "type": "object", + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": [ + "boolean", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "type": "integer", + "format": "int64" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ] + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "description": "Optional implementation-specific thread data.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ] + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, + "gitInfo": { + "description": "Optional Git metadata captured when the thread was created.", + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ] + }, + "historyMode": { + "description": "Persisted thread history contract selected when this thread was created.", + "default": "legacy", + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ] + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "isPinned": { + "description": "Whether the thread has been pinned by the user.", + "default": false, + "type": "boolean" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).", + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ] + }, + "status": { + "description": "Current runtime status for the thread.", + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ] + }, + "threadSource": { + "description": "Optional analytics source classification for this thread.", + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ] + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "type": "array", + "items": { + "$ref": "#/definitions/Turn" + } + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "type": "integer", + "format": "int64" + } + } + }, + "ThreadActiveFlag": { + "type": "string", + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ] + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadHistoryMode": { + "type": "string", + "enum": [ + "legacy", + "paginated" + ] + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType" + } + }, + "title": "NotLoadedThreadStatus" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType" + } + }, + "title": "IdleThreadStatus" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType" + } + }, + "title": "SystemErrorThreadStatus" + }, + { + "type": "object", + "required": [ + "activeFlags", + "type" + ], + "properties": { + "activeFlags": { + "type": "array", + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + } + }, + "type": { + "type": "string", + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType" + } + }, + "title": "ActiveThreadStatus" + } + ] + }, + "Turn": { + "type": "object", + "required": [ + "id", + "items", + "status" + ], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": [ + "notLoaded" + ] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": [ + "summary" + ] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": [ + "full" + ] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/ThreadTokenUsageUpdatedNotification.gen.json b/codexapp/internal/protocol/schema/v2/ThreadTokenUsageUpdatedNotification.gen.json new file mode 100644 index 0000000..bbc1268 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/ThreadTokenUsageUpdatedNotification.gen.json @@ -0,0 +1,82 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadTokenUsageUpdatedNotification", + "type": "object", + "required": [ + "threadId", + "tokenUsage", + "turnId" + ], + "properties": { + "threadId": { + "type": "string" + }, + "tokenUsage": { + "$ref": "#/definitions/ThreadTokenUsage" + }, + "turnId": { + "type": "string" + } + }, + "definitions": { + "ThreadTokenUsage": { + "type": "object", + "required": [ + "last", + "total" + ], + "properties": { + "last": { + "$ref": "#/definitions/TokenUsageBreakdown" + }, + "modelContextWindow": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "total": { + "$ref": "#/definitions/TokenUsageBreakdown" + } + } + }, + "TokenUsageBreakdown": { + "type": "object", + "required": [ + "cachedInputTokens", + "inputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens" + ], + "properties": { + "cacheWriteInputTokens": { + "default": 0, + "type": "integer", + "format": "int64" + }, + "cachedInputTokens": { + "type": "integer", + "format": "int64" + }, + "inputTokens": { + "type": "integer", + "format": "int64" + }, + "outputTokens": { + "type": "integer", + "format": "int64" + }, + "reasoningOutputTokens": { + "type": "integer", + "format": "int64" + }, + "totalTokens": { + "type": "integer", + "format": "int64" + } + } + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnCompletedNotification.gen.json b/codexapp/internal/protocol/schema/v2/TurnCompletedNotification.gen.json new file mode 100644 index 0000000..19c1365 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnCompletedNotification.gen.json @@ -0,0 +1,1898 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnCompletedNotification", + "type": "object", + "required": [ + "threadId", + "turn" + ], + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": [ + "httpConnectionFailed" + ], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": [ + "responseStreamConnectionFailed" + ], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": [ + "responseStreamDisconnected" + ], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": [ + "responseTooManyFailedAttempts" + ], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": [ + "activeTurnNotSteerable" + ], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": [ + "turnKind" + ], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": [ + "review", + "compact" + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "Turn": { + "type": "object", + "required": [ + "id", + "items", + "status" + ], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": [ + "notLoaded" + ] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": [ + "summary" + ] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": [ + "full" + ] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnDiffUpdatedNotification.gen.json b/codexapp/internal/protocol/schema/v2/TurnDiffUpdatedNotification.gen.json new file mode 100644 index 0000000..e439476 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnDiffUpdatedNotification.gen.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnDiffUpdatedNotification", + "description": "Notification that the turn-level unified diff has changed. Contains the latest aggregated diff across all file changes in the turn.", + "type": "object", + "required": [ + "diff", + "threadId", + "turnId" + ], + "properties": { + "diff": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnInterruptParams.gen.json b/codexapp/internal/protocol/schema/v2/TurnInterruptParams.gen.json new file mode 100644 index 0000000..f38a75e --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnInterruptParams.gen.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnInterruptParams", + "type": "object", + "required": [ + "threadId", + "turnId" + ], + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnInterruptResponse.gen.json b/codexapp/internal/protocol/schema/v2/TurnInterruptResponse.gen.json new file mode 100644 index 0000000..5d8a0f9 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnInterruptResponse.gen.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnInterruptResponse", + "type": "object" +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnPlanUpdatedNotification.gen.json b/codexapp/internal/protocol/schema/v2/TurnPlanUpdatedNotification.gen.json new file mode 100644 index 0000000..0f83538 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnPlanUpdatedNotification.gen.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnPlanUpdatedNotification", + "type": "object", + "required": [ + "plan", + "threadId", + "turnId" + ], + "properties": { + "explanation": { + "type": [ + "string", + "null" + ] + }, + "plan": { + "type": "array", + "items": { + "$ref": "#/definitions/TurnPlanStep" + } + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "definitions": { + "TurnPlanStep": { + "type": "object", + "required": [ + "status", + "step" + ], + "properties": { + "status": { + "$ref": "#/definitions/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + } + }, + "TurnPlanStepStatus": { + "type": "string", + "enum": [ + "pending", + "inProgress", + "completed" + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnStartParams.gen.json b/codexapp/internal/protocol/schema/v2/TurnStartParams.gen.json new file mode 100644 index 0000000..b9a12ec --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnStartParams.gen.json @@ -0,0 +1,748 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnStartParams", + "type": "object", + "required": [ + "input", + "threadId" + ], + "properties": { + "additionalContext": { + "description": "Optional client-provided context fragments keyed by an opaque source identifier.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/definitions/AdditionalContextEntry" + } + }, + "approvalPolicy": { + "description": "Override the approval policy for this turn and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "approvalsReviewer": { + "description": "Override where approval requests are routed for review on this turn and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + }, + { + "type": "null" + } + ] + }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, + "collaborationMode": { + "description": "EXPERIMENTAL - Set a pre-set collaboration mode. Takes precedence over model, reasoning_effort, and developer instructions if set.\n\nFor `collaboration_mode.settings.developer_instructions`, `null` means \"use the built-in instructions for the selected mode\".", + "anyOf": [ + { + "$ref": "#/definitions/CollaborationMode" + }, + { + "type": "null" + } + ] + }, + "cwd": { + "description": "Override the working directory for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "effort": { + "description": "Override the reasoning effort for this turn and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "environments": { + "description": "Optional environments for this turn and subsequent turns.\n\nOmitted uses the thread sticky environments. Empty disables environment access for this turn. Non-empty selects the first environment as the current turn environment for this turn.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/TurnEnvironmentParams" + } + }, + "input": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "model": { + "description": "Override the model for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "multiAgentMode": { + "description": "@deprecated Ignored. Use `effort: \"ultra\"` for proactive multi-agent behavior.", + "anyOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + }, + { + "type": "null" + } + ] + }, + "outputSchema": { + "description": "Optional JSON Schema used to constrain the final assistant message for this turn." + }, + "permissions": { + "description": "Select a named permissions profile id for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`.", + "type": [ + "string", + "null" + ] + }, + "personality": { + "description": "Override the personality for this turn and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "responsesapiClientMetadata": { + "description": "Optional metadata to enrich Codex's ResponsesAPI turn metadata.\n\nEntries are flattened into the JSON string sent as `client_metadata[\"x-codex-turn-metadata\"]` on ResponsesAPI HTTP and websocket requests.\n\nThey are not sent as top-level ResponsesAPI `client_metadata` keys, and reserved keys such as `session_id`, `thread_id`, `turn_id`, and `window_id` cannot be overridden.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "runtimeWorkspaceRoots": { + "description": "Replace the thread's runtime workspace roots for this turn and subsequent turns. Paths must be absolute.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + }, + "sandboxPolicy": { + "description": "Override the sandbox policy for this turn and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + }, + { + "type": "null" + } + ] + }, + "serviceTier": { + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Override the reasoning summary for this turn and subsequent turns.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalContextEntry": { + "type": "object", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + } + }, + "AdditionalContextKind": { + "type": "string", + "enum": [ + "untrusted", + "application" + ] + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "type": "string", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ] + }, + "AskForApproval": { + "oneOf": [ + { + "type": "string", + "enum": [ + "untrusted", + "on-request", + "never" + ] + }, + { + "type": "object", + "required": [ + "granular" + ], + "properties": { + "granular": { + "type": "object", + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + } + } + }, + "additionalProperties": false, + "title": "GranularAskForApproval" + } + ] + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "type": "object", + "required": [ + "mode", + "settings" + ], + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "type": "string", + "enum": [ + "plan", + "default" + ] + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "type": "string", + "enum": [ + "explicitRequestOnly", + "proactive" + ] + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "CustomMultiAgentMode" + } + ] + }, + "NetworkAccess": { + "type": "string", + "enum": [ + "restricted", + "enabled" + ] + }, + "Personality": { + "type": "string", + "enum": [ + "none", + "friendly", + "pragmatic" + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "type": "string", + "enum": [ + "auto", + "concise", + "detailed" + ] + }, + { + "description": "Option to disable reasoning summaries.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "SandboxPolicy": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType" + } + }, + "title": "DangerFullAccessSandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType" + } + }, + "title": "ReadOnlySandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "networkAccess": { + "default": "restricted", + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType" + } + }, + "title": "ExternalSandboxSandboxPolicy" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType" + }, + "writableRoots": { + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + } + } + }, + "title": "WorkspaceWriteSandboxPolicy" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "type": "object", + "required": [ + "model" + ], + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + } + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "TurnEnvironmentParams": { + "type": "object", + "required": [ + "cwd", + "environmentId" + ], + "properties": { + "cwd": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "environmentId": { + "type": "string" + }, + "runtimeWorkspaceRoots": { + "description": "Environment-native runtime workspace roots. Omitted defaults to `cwd`.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/LegacyAppPathString" + } + } + } + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnStartResponse.gen.json b/codexapp/internal/protocol/schema/v2/TurnStartResponse.gen.json new file mode 100644 index 0000000..b9f9685 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnStartResponse.gen.json @@ -0,0 +1,1894 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnStartResponse", + "type": "object", + "required": [ + "turn" + ], + "properties": { + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": [ + "httpConnectionFailed" + ], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": [ + "responseStreamConnectionFailed" + ], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": [ + "responseStreamDisconnected" + ], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": [ + "responseTooManyFailedAttempts" + ], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": [ + "activeTurnNotSteerable" + ], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": [ + "turnKind" + ], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": [ + "review", + "compact" + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "Turn": { + "type": "object", + "required": [ + "id", + "items", + "status" + ], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": [ + "notLoaded" + ] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": [ + "summary" + ] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": [ + "full" + ] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/TurnStartedNotification.gen.json b/codexapp/internal/protocol/schema/v2/TurnStartedNotification.gen.json new file mode 100644 index 0000000..8f5f7f4 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/TurnStartedNotification.gen.json @@ -0,0 +1,1898 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnStartedNotification", + "type": "object", + "required": [ + "threadId", + "turn" + ], + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ByteRange": { + "type": "object", + "required": [ + "end", + "start" + ], + "properties": { + "end": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, + "start": { + "type": "integer", + "format": "uint", + "minimum": 0.0 + } + } + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "type": "string", + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ] + }, + { + "type": "object", + "required": [ + "httpConnectionFailed" + ], + "properties": { + "httpConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "HttpConnectionFailedCodexErrorInfo" + }, + { + "description": "Failed to connect to the response SSE stream.", + "type": "object", + "required": [ + "responseStreamConnectionFailed" + ], + "properties": { + "responseStreamConnectionFailed": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamConnectionFailedCodexErrorInfo" + }, + { + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "type": "object", + "required": [ + "responseStreamDisconnected" + ], + "properties": { + "responseStreamDisconnected": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseStreamDisconnectedCodexErrorInfo" + }, + { + "description": "Reached the retry limit for responses.", + "type": "object", + "required": [ + "responseTooManyFailedAttempts" + ], + "properties": { + "responseTooManyFailedAttempts": { + "type": "object", + "properties": { + "httpStatusCode": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false, + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo" + }, + { + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "type": "object", + "required": [ + "activeTurnNotSteerable" + ], + "properties": { + "activeTurnNotSteerable": { + "type": "object", + "required": [ + "turnKind" + ], + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + } + } + }, + "additionalProperties": false, + "title": "ActiveTurnNotSteerableCodexErrorInfo" + } + ] + }, + "CollabAgentState": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + } + }, + "CollabAgentStatus": { + "type": "string", + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ] + }, + "CollabAgentTool": { + "type": "string", + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ] + }, + "CollabAgentToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "CommandAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "name", + "path", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "type": "string", + "enum": [ + "read" + ], + "title": "ReadCommandActionType" + } + }, + "title": "ReadCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType" + } + }, + "title": "ListFilesCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchCommandActionType" + } + }, + "title": "SearchCommandAction" + }, + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType" + } + }, + "title": "UnknownCommandAction" + } + ] + }, + "CommandExecutionSource": { + "type": "string", + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ] + }, + "CommandExecutionStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType" + } + }, + "title": "InputTextDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "imageUrl", + "type" + ], + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType" + } + }, + "title": "InputImageDynamicToolCallOutputContentItem" + }, + { + "type": "object", + "required": [ + "audioUrl", + "type" + ], + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "inputAudio" + ], + "title": "InputAudioDynamicToolCallOutputContentItemType" + } + }, + "title": "InputAudioDynamicToolCallOutputContentItem" + } + ] + }, + "DynamicToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "FileUpdateChange": { + "type": "object", + "required": [ + "diff", + "kind", + "path" + ], + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + } + }, + "HookPromptFragment": { + "type": "object", + "required": [ + "hookRunId", + "text" + ], + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, + "ImageDetail": { + "type": "string", + "enum": [ + "auto", + "low", + "high", + "original" + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "type": "object", + "required": [ + "connectorId" + ], + "properties": { + "actionName": { + "type": [ + "string", + "null" + ] + }, + "appName": { + "type": [ + "string", + "null" + ] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": [ + "string", + "null" + ] + }, + "resourceUri": { + "type": [ + "string", + "null" + ] + } + } + }, + "McpToolCallError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "McpToolCallResult": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "_meta": true, + "content": { + "type": "array", + "items": true + }, + "structuredContent": true + } + }, + "McpToolCallStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed" + ] + }, + "MemoryCitation": { + "type": "object", + "required": [ + "entries", + "threadIds" + ], + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + } + }, + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "MemoryCitationEntry": { + "type": "object", + "required": [ + "lineEnd", + "lineStart", + "note", + "path" + ], + "properties": { + "lineEnd": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "lineStart": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "type": "string", + "enum": [ + "commentary" + ] + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "type": "string", + "enum": [ + "final_answer" + ] + } + ] + }, + "NonSteerableTurnKind": { + "type": "string", + "enum": [ + "review", + "compact" + ] + }, + "PatchApplyStatus": { + "type": "string", + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ] + }, + "PatchChangeKind": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType" + } + }, + "title": "AddPatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType" + } + }, + "title": "DeletePatchChangeKind" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType" + } + }, + "title": "UpdatePatchChangeKind" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "type": "string", + "minLength": 1 + }, + "SubAgentActivityKind": { + "type": "string", + "enum": [ + "started", + "interacted", + "interrupted" + ] + }, + "TextElement": { + "type": "object", + "required": [ + "byteRange" + ], + "properties": { + "byteRange": { + "description": "Byte range in the parent `text` buffer that this element occupies.", + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ] + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + } + }, + "ThreadItem": { + "oneOf": [ + { + "type": "object", + "required": [ + "content", + "id", + "type" + ], + "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "array", + "items": { + "$ref": "#/definitions/UserInput" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType" + } + }, + "title": "UserMessageThreadItem" + }, + { + "type": "object", + "required": [ + "fragments", + "id", + "type" + ], + "properties": { + "fragments": { + "type": "array", + "items": { + "$ref": "#/definitions/HookPromptFragment" + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "hookPrompt" + ], + "title": "HookPromptThreadItemType" + } + }, + "title": "HookPromptThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "memoryCitation": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ] + }, + "phase": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType" + } + }, + "title": "AgentMessageThreadItem" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "type": "object", + "required": [ + "id", + "text", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "plan" + ], + "title": "PlanThreadItemType" + } + }, + "title": "PlanThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "content": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType" + } + }, + "title": "ReasoningThreadItem" + }, + { + "type": "object", + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "type": "array", + "items": { + "$ref": "#/definitions/CommandAction" + } + }, + "cwd": { + "description": "The command's working directory.", + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ] + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "exitCode": { + "description": "The command's exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "string" + }, + "pluginId": { + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "scriptPath": { + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "default": null, + "type": [ + "string", + "null" + ] + }, + "source": { + "default": "agent", + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "type": "string", + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType" + } + }, + "title": "CommandExecutionThreadItem" + }, + { + "type": "object", + "required": [ + "changes", + "id", + "status", + "type" + ], + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/FileUpdateChange" + } + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "type": "string", + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType" + } + }, + "title": "FileChangeThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType" + } + }, + "title": "McpToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "properties": { + "arguments": true, + "contentItems": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + } + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "id": { + "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType" + } + }, + "title": "DynamicToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "properties": { + "agentsStates": { + "description": "Last known status of the target agents, when available.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + } + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": [ + "string", + "null" + ] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "reasoningEffort": { + "description": "Reasoning effort requested for the spawned agent, when applicable.", + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "type": "array", + "items": { + "type": "string" + } + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "description": "Current status of the collab tool call.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ] + }, + "tool": { + "description": "Name of the collab tool that was invoked.", + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType" + } + }, + "title": "CollabAgentToolCallThreadItem" + }, + { + "type": "object", + "required": [ + "agentPath", + "agentThreadId", + "id", + "kind", + "type" + ], + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "type": "string", + "enum": [ + "subAgentActivity" + ], + "title": "SubAgentActivityThreadItemType" + } + }, + "title": "SubAgentActivityThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "query", + "type" + ], + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "default": null, + "type": [ + "array", + "null" + ], + "items": true + }, + "type": { + "type": "string", + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType" + } + }, + "title": "WebSearchThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "path", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "type": "string", + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType" + } + }, + "title": "ImageViewThreadItem" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "type": "object", + "required": [ + "durationMs", + "id", + "type" + ], + "properties": { + "durationMs": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sleep" + ], + "title": "SleepThreadItemType" + } + }, + "title": "SleepThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "result", + "status", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": [ + "string", + "null" + ] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "imageGeneration" + ], + "title": "ImageGenerationThreadItemType" + } + }, + "title": "ImageGenerationThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType" + } + }, + "title": "EnteredReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "review", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType" + } + }, + "title": "ExitedReviewModeThreadItem" + }, + { + "type": "object", + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType" + } + }, + "title": "ContextCompactionThreadItem" + } + ] + }, + "Turn": { + "type": "object", + "required": [ + "id", + "items", + "status" + ], + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "error": { + "description": "Only populated when the Turn's status is failed.", + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ] + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "type": "array", + "items": { + "$ref": "#/definitions/ThreadItem" + } + }, + "itemsView": { + "description": "Describes how much of `items` has been loaded for this turn.", + "default": "full", + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ] + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + } + }, + "TurnError": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + } + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "type": "string", + "enum": [ + "notLoaded" + ] + }, + { + "description": "`items` contains only a display summary for this turn.", + "type": "string", + "enum": [ + "summary" + ] + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "type": "string", + "enum": [ + "full" + ] + } + ] + }, + "TurnStatus": { + "type": "string", + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ] + }, + "UserInput": { + "oneOf": [ + { + "type": "object", + "required": [ + "text", + "type" + ], + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "description": "UI-defined spans within `text` used to render or persist special elements.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/TextElement" + } + }, + "type": { + "type": "string", + "enum": [ + "text" + ], + "title": "TextUserInputType" + } + }, + "title": "TextUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ], + "title": "ImageUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "ImageUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "detail": { + "default": null, + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType" + } + }, + "title": "LocalImageUserInput" + }, + { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "audio" + ], + "title": "AudioUserInputType" + }, + "url": { + "type": "string" + } + }, + "title": "AudioUserInput" + }, + { + "type": "object", + "required": [ + "path", + "type" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "localAudio" + ], + "title": "LocalAudioUserInputType" + } + }, + "title": "LocalAudioUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "skill" + ], + "title": "SkillUserInputType" + } + }, + "title": "SkillUserInput" + }, + { + "type": "object", + "required": [ + "name", + "path", + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "mention" + ], + "title": "MentionUserInputType" + } + }, + "title": "MentionUserInput" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "queries": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType" + } + }, + "title": "SearchWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openPage" + ], + "title": "OpenPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "OpenPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "findInPage" + ], + "title": "FindInPageWebSearchActionType" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "title": "FindInPageWebSearchAction" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType" + } + }, + "title": "OtherWebSearchAction" + } + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/schema/v2/WarningNotification.gen.json b/codexapp/internal/protocol/schema/v2/WarningNotification.gen.json new file mode 100644 index 0000000..9899117 --- /dev/null +++ b/codexapp/internal/protocol/schema/v2/WarningNotification.gen.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WarningNotification", + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": [ + "string", + "null" + ] + } + } +} \ No newline at end of file diff --git a/codexapp/internal/protocol/version.gen.go b/codexapp/internal/protocol/version.gen.go new file mode 100644 index 0000000..ad28201 --- /dev/null +++ b/codexapp/internal/protocol/version.gen.go @@ -0,0 +1,5 @@ +// Code generated by codexapp protocol generator. DO NOT EDIT. + +package protocol + +const CodexVersion = "codex-cli 0.146.0" diff --git a/codexapp/mocks/server_requests.gen.go b/codexapp/mocks/server_requests.gen.go new file mode 100644 index 0000000..3fd50fc --- /dev/null +++ b/codexapp/mocks/server_requests.gen.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/codexapp (interfaces: ServerRequestHandler) +// +// Generated by this command: +// +// mockgen -destination mocks/server_requests.gen.go -package mocks . ServerRequestHandler +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + codexapp "github.com/devctllabs/go-libs/codexapp" + gomock "go.uber.org/mock/gomock" +) + +// MockServerRequestHandler is a mock of ServerRequestHandler interface. +type MockServerRequestHandler struct { + ctrl *gomock.Controller + recorder *MockServerRequestHandlerMockRecorder + isgomock struct{} +} + +// MockServerRequestHandlerMockRecorder is the mock recorder for MockServerRequestHandler. +type MockServerRequestHandlerMockRecorder struct { + mock *MockServerRequestHandler +} + +// NewMockServerRequestHandler creates a new mock instance. +func NewMockServerRequestHandler(ctrl *gomock.Controller) *MockServerRequestHandler { + mock := &MockServerRequestHandler{ctrl: ctrl} + mock.recorder = &MockServerRequestHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockServerRequestHandler) EXPECT() *MockServerRequestHandlerMockRecorder { + return m.recorder +} + +// HandleServerRequest mocks base method. +func (m *MockServerRequestHandler) HandleServerRequest(ctx context.Context, request codexapp.ServerRequest) (codexapp.ServerResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleServerRequest", ctx, request) + ret0, _ := ret[0].(codexapp.ServerResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HandleServerRequest indicates an expected call of HandleServerRequest. +func (mr *MockServerRequestHandlerMockRecorder) HandleServerRequest(ctx, request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleServerRequest", reflect.TypeOf((*MockServerRequestHandler)(nil).HandleServerRequest), ctx, request) +} diff --git a/codexapp/notification_test.go b/codexapp/notification_test.go new file mode 100644 index 0000000..95654aa --- /dev/null +++ b/codexapp/notification_test.go @@ -0,0 +1,160 @@ +package codexapp + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + notificationThreadID = "thread" + notificationTurnID = "turn" +) + +func TestNotificationFamiliesMapToOneTypedPayload(t *testing.T) { + t.Parallel() + handle := &TurnHandle{threadID: "thread", stream: newEventStream(4), done: make(chan struct{})} + client := &Client{ + turns: map[string]*TurnHandle{"turn": handle}, + starting: map[string]*TurnHandle{}, + } + client.dispatchNotification(string(EventCommandOutputDelta), json.RawMessage(`{"threadId":"thread","turnId":"turn","delta":"ok"}`)) + client.dispatchNotification(string(EventTurnDiffUpdated), json.RawMessage(`{"threadId":"thread","turnId":"turn","diff":"+line"}`)) + client.dispatchNotification(string(EventWarning), json.RawMessage(`{"threadId":"thread","message":"careful"}`)) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + event, err := handle.Events().Next(ctx) + require.NoError(t, err) + require.Equal(t, &CommandOutputDelta{Text: "ok"}, event.CommandOutput) + require.Nil(t, event.Diff) + event, err = handle.Events().Next(ctx) + require.NoError(t, err) + require.Equal(t, &DiffUpdate{Diff: "+line"}, event.Diff) + require.Nil(t, event.CommandOutput) + event, err = handle.Events().Next(ctx) + require.NoError(t, err) + require.Equal(t, &WarningEvent{Message: "careful"}, event.Warning) +} + +func TestNotificationVariantsPreserveTypedPayloads(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method EventType + params json.RawMessage + expected Event + }{ + { + name: "plan delta", + method: EventPlanDelta, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","delta":"step"}`), + expected: Event{ + Type: EventPlanDelta, ThreadID: notificationThreadID, TurnID: notificationTurnID, + PlanDelta: &PlanDelta{Text: "step"}, + }, + }, + { + name: "reasoning summary delta", + method: EventReasoningSummaryDelta, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","delta":"summary"}`), + expected: Event{ + Type: EventReasoningSummaryDelta, ThreadID: notificationThreadID, TurnID: notificationTurnID, + TextDelta: &TextDelta{Text: "summary"}, + }, + }, + { + name: "reasoning text delta", + method: EventReasoningTextDelta, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","delta":"reasoning"}`), + expected: Event{ + Type: EventReasoningTextDelta, ThreadID: notificationThreadID, TurnID: notificationTurnID, + TextDelta: &TextDelta{Text: "reasoning"}, + }, + }, + { + name: "plan updated", + method: EventTurnPlanUpdated, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","explanation":"why","plan":[{"step":"do it","status":"pending"}]}`), + expected: Event{ + Type: EventTurnPlanUpdated, ThreadID: notificationThreadID, TurnID: notificationTurnID, + Plan: &PlanUpdate{Explanation: "why", Steps: []PlanStep{{Step: "do it", Status: "pending"}}}, + }, + }, + { + name: "item started", + method: EventItemStarted, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"id":"item","type":"message"}}`), + expected: Event{ + Type: EventItemStarted, ThreadID: notificationThreadID, TurnID: notificationTurnID, + Item: &Item{ID: "item", Type: "message", Raw: json.RawMessage(`{"id":"item","type":"message"}`)}, + }, + }, + { + name: "item completed", + method: EventItemCompleted, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"id":"item","type":"message"}}`), + expected: Event{ + Type: EventItemCompleted, ThreadID: notificationThreadID, TurnID: notificationTurnID, + Item: &Item{ID: "item", Type: "message", Raw: json.RawMessage(`{"id":"item","type":"message"}`)}, + }, + }, + { + name: "error", + method: EventError, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","willRetry":true,"error":{"message":"failed"}}`), + expected: Event{ + Type: EventError, ThreadID: notificationThreadID, TurnID: notificationTurnID, + Failure: &FailureEvent{Message: "failed", WillRetry: true}, + }, + }, + { + name: "token usage", + method: EventTokenUsageUpdated, + params: json.RawMessage(`{"threadId":"thread","turnId":"turn","tokenUsage":{"total":42}}`), + expected: Event{ + Type: EventTokenUsageUpdated, ThreadID: notificationThreadID, TurnID: notificationTurnID, + TokenUsage: &TokenUsageEvent{Raw: json.RawMessage(`{"total":42}`)}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + handle := &TurnHandle{threadID: notificationThreadID, stream: newEventStream(1), done: make(chan struct{})} + client := &Client{ + turns: map[string]*TurnHandle{notificationTurnID: handle}, + starting: map[string]*TurnHandle{}, + } + + client.dispatchNotification(string(test.method), test.params) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + event, err := handle.Events().Next(ctx) + require.NoError(t, err) + require.Equal(t, test.expected, event) + }) + } +} + +func TestNotificationDispatcherIgnoresMalformedAndUnknownMessages(t *testing.T) { + t.Parallel() + + handle := &TurnHandle{threadID: notificationThreadID, stream: newEventStream(1), done: make(chan struct{})} + client := &Client{ + turns: map[string]*TurnHandle{notificationTurnID: handle}, + starting: map[string]*TurnHandle{}, + } + + client.dispatchNotification(string(EventWarning), json.RawMessage(`{"threadId":`)) + client.dispatchNotification("future/event", json.RawMessage(`{"threadId":"thread","turnId":"turn"}`)) + + require.Empty(t, handle.stream.events) +} diff --git a/codexapp/permissions.go b/codexapp/permissions.go new file mode 100644 index 0000000..6354e48 --- /dev/null +++ b/codexapp/permissions.go @@ -0,0 +1,91 @@ +package codexapp + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" +) + +type normalizedPermissionProfile struct { + ID string + WriteRoots []string + Config permissionProfileConfig +} + +type permissionProfileConfig struct { + Filesystem map[string]string `json:"filesystem"` + Network permissionNetworkConfig `json:"network"` +} + +type permissionNetworkConfig struct { + Enabled bool `json:"enabled"` +} + +func (p Permissions) empty() bool { + return len(p.ReadRoots) == 0 && len(p.WriteRoots) == 0 && !p.NetworkEnabled +} + +func buildPermissionProfile(permissions Permissions) (normalizedPermissionProfile, error) { + readRoots, err := normalizeRoots(permissions.ReadRoots) + if err != nil { + return normalizedPermissionProfile{}, fmt.Errorf("codexapp: invalid read roots: %w", err) + } + writeRoots, err := normalizeRoots(permissions.WriteRoots) + if err != nil { + return normalizedPermissionProfile{}, fmt.Errorf("codexapp: invalid write roots: %w", err) + } + filesystem := map[string]string{":root": "deny", ":minimal": "read"} + for _, root := range readRoots { + filesystem[root] = "read" + } + for _, root := range writeRoots { + filesystem[root] = "write" + } + config := permissionProfileConfig{ + Filesystem: filesystem, + Network: permissionNetworkConfig{Enabled: permissions.NetworkEnabled}, + } + canonical := struct { + ReadRoots []string `json:"readRoots"` + WriteRoots []string `json:"writeRoots"` + NetworkEnabled bool `json:"networkEnabled"` + }{readRoots, writeRoots, permissions.NetworkEnabled} + encoded, err := json.Marshal(canonical) + if err != nil { + return normalizedPermissionProfile{}, fmt.Errorf("encode permission profile: %w", err) + } + sum := sha256.Sum256(encoded) + return normalizedPermissionProfile{ + ID: "codexapp-" + hex.EncodeToString(sum[:8]), + WriteRoots: writeRoots, + Config: config, + }, nil +} + +func normalizeRoots(roots []string) ([]string, error) { + unique := make(map[string]struct{}, len(roots)) + for _, raw := range roots { + root := filepath.Clean(strings.TrimSpace(raw)) + if raw == "" || root == "." { + return nil, fmt.Errorf("root must not be blank") + } + if !filepath.IsAbs(root) { + return nil, fmt.Errorf("root %q must be absolute", raw) + } + volumeRoot := filepath.Clean(filepath.VolumeName(root) + string(filepath.Separator)) + if root == volumeRoot { + return nil, fmt.Errorf("filesystem root %q is not allowed", root) + } + unique[root] = struct{}{} + } + normalized := make([]string, 0, len(unique)) + for root := range unique { + normalized = append(normalized, root) + } + sort.Strings(normalized) + return normalized, nil +} diff --git a/codexapp/rpc.go b/codexapp/rpc.go new file mode 100644 index 0000000..7cd2502 --- /dev/null +++ b/codexapp/rpc.go @@ -0,0 +1,193 @@ +package codexapp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" +) + +const ( + defaultMaxMessageBytes = 64 << 20 + jsonRPCVersion = "2.0" +) + +type initializeParams struct { + Capabilities *initializeCapabilities `json:"capabilities,omitempty"` + ClientInfo ClientInfo `json:"clientInfo"` +} + +type initializeCapabilities struct { + ExperimentalAPI *bool `json:"experimentalApi,omitempty"` +} + +type modelListParams struct { + Cursor *string `json:"cursor,omitempty"` + Limit *int64 `json:"limit,omitempty"` + IncludeHidden *bool `json:"includeHidden,omitempty"` +} + +type permissionProfileListParams struct{} + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id,omitempty"` + Method string `json:"method"` + Params any `json:"params,omitempty"` + Trace map[string]string `json:"trace,omitempty"` +} + +type rpcResponse struct { + result json.RawMessage + err error +} + +// RPCError is an error object returned by App Server. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (e RPCError) Error() string { + return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message) +} + +func (c *Client) call(ctx context.Context, method string, params any, result any) (err error) { + if ctx == nil { + return fmt.Errorf("context is required") + } + ctx, finish := c.instrumentation.startRPC(ctx, method) + defer func() { finish(err) }() + id := c.nextID.Add(1) + response := make(chan rpcResponse, 1) + c.pendingMu.Lock() + c.pending[id] = response + c.pendingMu.Unlock() + + traceCarrier := c.instrumentation.inject(ctx) + if err := c.write(rpcRequest{JSONRPC: jsonRPCVersion, ID: id, Method: method, Params: params, Trace: traceCarrier}); err != nil { + c.removePending(id) + return err + } + select { + case received := <-response: + if received.err != nil { + return received.err + } + if result == nil { + return nil + } + if err := json.Unmarshal(received.result, result); err != nil { + return fmt.Errorf("decode %s response: %w", method, err) + } + return nil + case <-ctx.Done(): + c.removePending(id) + return &CallError{Method: method, Cause: ctx.Err(), OutcomeUnknown: true} + case <-c.done: + c.removePending(id) + return c.sessionError() + } +} + +func (c *Client) notify(method string, params any) error { + return c.write(rpcRequest{JSONRPC: jsonRPCVersion, Method: method, Params: params}) +} + +func (c *Client) write(message rpcRequest) error { + return c.writeJSON(message, message.Method+" request") +} + +func (c *Client) writeJSON(message any, operation string) error { + encoded, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("encode %s: %w", operation, err) + } + encoded = append(encoded, '\n') + c.writeMu.Lock() + defer c.writeMu.Unlock() + if _, err := c.stdin.Write(encoded); err != nil { + return fmt.Errorf("write %s: %w", operation, err) + } + return nil +} + +func (c *Client) readLoop(stdout io.Reader, maxMessageBytes int) { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64<<10), maxMessageBytes) + for scanner.Scan() { + var envelope struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Trace map[string]string `json:"trace"` + Result json.RawMessage `json:"result"` + Error *RPCError `json:"error"` + } + if err := json.Unmarshal(scanner.Bytes(), &envelope); err != nil { + c.finish(&ProtocolError{Operation: "decode app server message", Cause: err}) + return + } + if len(envelope.ID) == 0 || string(envelope.ID) == "null" { + c.dispatchNotification(envelope.Method, envelope.Params) + continue + } + if envelope.Method != "" { + c.dispatchServerRequest(envelope.ID, envelope.Method, envelope.Params, envelope.Trace) + continue + } + var responseID int64 + if err := json.Unmarshal(envelope.ID, &responseID); err != nil { + c.finish(&ProtocolError{Operation: "decode response id", Cause: err}) + return + } + c.pendingMu.Lock() + response, ok := c.pending[responseID] + if ok { + delete(c.pending, responseID) + } + c.pendingMu.Unlock() + if !ok { + continue + } + if envelope.Error != nil { + response <- rpcResponse{err: envelope.Error} + continue + } + response <- rpcResponse{result: envelope.Result} + } + if err := scanner.Err(); err != nil { + c.finish(&ProtocolError{Operation: "read app server message", Cause: err}) + return + } + // The process waiter owns clean EOF classification so an immediately following non-zero + // exit is not lost to a race with stdout closure. +} + +func (c *Client) removePending(id int64) { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() +} + +func (c *Client) finish(err error) { + c.doneOnce.Do(func() { + c.handlerCancel() + c.terminalMu.Lock() + c.terminalErr = err + c.terminalMu.Unlock() + c.failTurns(err) + close(c.done) + }) +} + +func (c *Client) sessionError() error { + c.terminalMu.Lock() + defer c.terminalMu.Unlock() + if c.terminalErr == nil { + return io.EOF + } + return c.terminalErr +} diff --git a/codexapp/server_requests.go b/codexapp/server_requests.go new file mode 100644 index 0000000..75d2693 --- /dev/null +++ b/codexapp/server_requests.go @@ -0,0 +1,324 @@ +package codexapp + +import ( + "context" + "encoding/json" + "fmt" +) + +//go:generate go tool mockgen -destination mocks/server_requests.gen.go -package mocks . ServerRequestHandler + +// ServerRequestHandler handles a request initiated by App Server. +type ServerRequestHandler interface { + // HandleServerRequest decides a server-initiated request. Implementations may block; the + // protocol reader continues independently and the context ends with the client session. + HandleServerRequest(ctx context.Context, request ServerRequest) (ServerResponse, error) +} + +// ServerRequestType identifies the payload populated on ServerRequest. +type ServerRequestType string + +const ( + // ServerRequestCommandApproval asks whether a command may execute. + ServerRequestCommandApproval ServerRequestType = "item/commandExecution/requestApproval" + // ServerRequestFileChangeApproval asks whether file modifications may be applied. + ServerRequestFileChangeApproval ServerRequestType = "item/fileChange/requestApproval" + // ServerRequestPermissionApproval asks for additional sandbox permissions. + ServerRequestPermissionApproval ServerRequestType = "item/permissions/requestApproval" + // ServerRequestUserInput asks the host to answer structured questions. + ServerRequestUserInput ServerRequestType = "item/tool/requestUserInput" + // ServerRequestMCPElicitation forwards an MCP server elicitation. + ServerRequestMCPElicitation ServerRequestType = "mcpServer/elicitation/request" +) + +// ServerRequest is a tagged union for requests initiated by App Server. +type ServerRequest struct { + Type ServerRequestType + ThreadID string + TurnID string + ItemID string + CommandApproval *CommandApprovalRequest + FileChangeApproval *FileChangeApprovalRequest + PermissionApproval *PermissionApprovalRequest + UserInput *UserInputRequest + MCPElicitation *MCPElicitationRequest +} + +// CommandApprovalRequest describes a command execution approval request. +type CommandApprovalRequest struct { + Command string + Cwd string + Reason string + AvailableDecisions []string + StartedAtMS int64 +} + +// FileChangeApprovalRequest describes a file-change approval request. +type FileChangeApprovalRequest struct { + Reason string + GrantRoot string + StartedAtMS int64 +} + +// PermissionApprovalRequest preserves the requested profile as schema-versioned JSON. +type PermissionApprovalRequest struct { + Cwd string + Reason string + StartedAtMS int64 + Requested json.RawMessage +} + +// UserInputRequest contains structured questions from a tool call. +type UserInputRequest struct { + Questions []UserInputQuestion + AutoResolutionMS *int64 +} + +// UserInputQuestion describes one structured question. +type UserInputQuestion struct { + ID string + Header string + Question string + Options []UserInputOption + IsOther bool + IsSecret bool +} + +// UserInputOption is one selectable answer. +type UserInputOption struct { + Label string + Description string +} + +// MCPElicitationRequest describes a downstream MCP elicitation. +type MCPElicitationRequest struct { + ServerName string + Mode string + Message string + URL string + RequestedSchema json.RawMessage +} + +// ServerResponse is a typed response to a ServerRequest. +type ServerResponse struct { + result any + decision string +} + +// AcceptCommandForSession approves the command and equivalent requests for this session. +func AcceptCommandForSession() ServerResponse { + return decisionResponse("acceptForSession") +} + +// AcceptCommand approves one command. +func AcceptCommand() ServerResponse { return decisionResponse("accept") } + +// DeclineCommand declines one command. +func DeclineCommand() ServerResponse { return decisionResponse("decline") } + +// CancelCommand cancels the approval flow. +func CancelCommand() ServerResponse { return decisionResponse("cancel") } + +// AnswerUserInput responds to structured questions by question id. +func AnswerUserInput(answers map[string][]string) ServerResponse { + wire := make(map[string]struct { + Answers []string `json:"answers"` + }, len(answers)) + for id, values := range answers { + wire[id] = struct { + Answers []string `json:"answers"` + }{Answers: append([]string(nil), values...)} + } + return ServerResponse{result: struct { + Answers any `json:"answers"` + }{Answers: wire}} +} + +// DeclineMCPElicitation declines an MCP elicitation without content. +func DeclineMCPElicitation() ServerResponse { + return ServerResponse{result: struct { + Action string `json:"action"` + Content any `json:"content"` + }{Action: "decline"}} +} + +func decisionResponse(decision string) ServerResponse { + return ServerResponse{decision: decision, result: struct { + Decision string `json:"decision"` + }{Decision: decision}} +} + +func decodeServerRequest(method string, params json.RawMessage) (ServerRequest, error) { + var common struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + ItemID string `json:"itemId"` + } + if err := json.Unmarshal(params, &common); err != nil { + return ServerRequest{}, fmt.Errorf("decode %s params: %w", method, err) + } + request := ServerRequest{Type: ServerRequestType(method), ThreadID: common.ThreadID, TurnID: common.TurnID, ItemID: common.ItemID} + var err error + switch request.Type { + case ServerRequestCommandApproval: + request.CommandApproval, err = decodeCommandApproval(params) + case ServerRequestFileChangeApproval: + request.FileChangeApproval, err = decodeFileChangeApproval(params) + case ServerRequestPermissionApproval: + request.PermissionApproval, err = decodePermissionApproval(params) + case ServerRequestUserInput: + request.UserInput, err = decodeUserInput(params) + case ServerRequestMCPElicitation: + request.MCPElicitation, err = decodeMCPElicitation(params) + default: + return ServerRequest{}, fmt.Errorf("unsupported server request method %q", method) + } + if err != nil { + return ServerRequest{}, err + } + return request, nil +} + +func decodeCommandApproval(params json.RawMessage) (*CommandApprovalRequest, error) { + var raw struct { + Command string `json:"command"` + Cwd string `json:"cwd"` + Reason string `json:"reason"` + AvailableDecisions []string `json:"availableDecisions"` + StartedAtMS int64 `json:"startedAtMs"` + } + if err := json.Unmarshal(params, &raw); err != nil { + return nil, err + } + return &CommandApprovalRequest{ + Command: raw.Command, Cwd: raw.Cwd, Reason: raw.Reason, + AvailableDecisions: append([]string(nil), raw.AvailableDecisions...), StartedAtMS: raw.StartedAtMS, + }, nil +} + +func decodeFileChangeApproval(params json.RawMessage) (*FileChangeApprovalRequest, error) { + var raw struct { + Reason string `json:"reason"` + GrantRoot string `json:"grantRoot"` + StartedAtMS int64 `json:"startedAtMs"` + } + if err := json.Unmarshal(params, &raw); err != nil { + return nil, err + } + approval := FileChangeApprovalRequest(raw) + return &approval, nil +} + +func decodePermissionApproval(params json.RawMessage) (*PermissionApprovalRequest, error) { + var raw struct { + Cwd string `json:"cwd"` + Reason string `json:"reason"` + StartedAtMS int64 `json:"startedAtMs"` + Permissions json.RawMessage `json:"permissions"` + } + if err := json.Unmarshal(params, &raw); err != nil { + return nil, err + } + return &PermissionApprovalRequest{ + Cwd: raw.Cwd, Reason: raw.Reason, StartedAtMS: raw.StartedAtMS, + Requested: append(json.RawMessage(nil), raw.Permissions...), + }, nil +} + +func decodeUserInput(params json.RawMessage) (*UserInputRequest, error) { + var raw struct { + AutoResolutionMS *int64 `json:"autoResolutionMs"` + Questions []struct { + ID string `json:"id"` + Header string `json:"header"` + Question string `json:"question"` + IsOther bool `json:"isOther"` + IsSecret bool `json:"isSecret"` + Options []struct { + Label string `json:"label"` + Description string `json:"description"` + } `json:"options"` + } `json:"questions"` + } + if err := json.Unmarshal(params, &raw); err != nil { + return nil, err + } + questions := make([]UserInputQuestion, 0, len(raw.Questions)) + for _, question := range raw.Questions { + options := make([]UserInputOption, 0, len(question.Options)) + for _, option := range question.Options { + options = append(options, UserInputOption(option)) + } + questions = append(questions, UserInputQuestion{ + ID: question.ID, Header: question.Header, Question: question.Question, + Options: options, IsOther: question.IsOther, IsSecret: question.IsSecret, + }) + } + return &UserInputRequest{Questions: questions, AutoResolutionMS: raw.AutoResolutionMS}, nil +} + +func decodeMCPElicitation(params json.RawMessage) (*MCPElicitationRequest, error) { + var raw struct { + ServerName string `json:"serverName"` + Mode string `json:"mode"` + Message string `json:"message"` + URL string `json:"url"` + RequestedSchema json.RawMessage `json:"requestedSchema"` + } + if err := json.Unmarshal(params, &raw); err != nil { + return nil, err + } + return &MCPElicitationRequest{ + ServerName: raw.ServerName, Mode: raw.Mode, Message: raw.Message, URL: raw.URL, + RequestedSchema: append(json.RawMessage(nil), raw.RequestedSchema...), + }, nil +} + +func (c *Client) dispatchServerRequest(id json.RawMessage, method string, params json.RawMessage, traceCarrier map[string]string) { + request, err := decodeServerRequest(method, params) + if err != nil { + go func() { + _ = c.writeJSON(struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Error RPCError `json:"error"` + }{"2.0", id, RPCError{Code: -32601, Message: "method not supported"}}, "server request error") + }() + return + } + requestCtx := c.instrumentation.extract(c.handlerCtx, traceCarrier) + go c.handleServerRequest(requestCtx, id, request) +} + +func (c *Client) handleServerRequest(ctx context.Context, id json.RawMessage, request ServerRequest) { + response := defaultServerResponse(request.Type) + if c.serverRequestHandler != nil { + selected, err := c.serverRequestHandler.HandleServerRequest(ctx, request) + if err == nil && selected.result != nil { + response = selected + } + } + _ = c.writeJSON(struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result"` + }{JSONRPC: "2.0", ID: id, Result: response.result}, "server request response") +} + +func defaultServerResponse(requestType ServerRequestType) ServerResponse { + switch requestType { + case ServerRequestCommandApproval, ServerRequestFileChangeApproval: + return DeclineCommand() + case ServerRequestPermissionApproval: + return ServerResponse{result: struct { + Permissions map[string]any `json:"permissions"` + Scope string `json:"scope"` + }{Permissions: map[string]any{}, Scope: "turn"}} + case ServerRequestUserInput: + return AnswerUserInput(map[string][]string{}) + case ServerRequestMCPElicitation: + return DeclineMCPElicitation() + default: + return DeclineCommand() + } +} diff --git a/codexapp/server_requests_test.go b/codexapp/server_requests_test.go new file mode 100644 index 0000000..538d5d1 --- /dev/null +++ b/codexapp/server_requests_test.go @@ -0,0 +1,65 @@ +package codexapp + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeServerRequestVariants(t *testing.T) { + t.Parallel() + tests := []struct { + name string + method ServerRequestType + params string + assertions func(*testing.T, ServerRequest) + }{ + { + name: "file change", + method: ServerRequestFileChangeApproval, + params: `{"threadId":"thread","turnId":"turn","itemId":"item","reason":"write","grantRoot":"/workspace","startedAtMs":12}`, + assertions: func(t *testing.T, request ServerRequest) { + require.Equal(t, &FileChangeApprovalRequest{Reason: "write", GrantRoot: "/workspace", StartedAtMS: 12}, request.FileChangeApproval) + }, + }, + { + name: "permissions", + method: ServerRequestPermissionApproval, + params: `{"threadId":"thread","turnId":"turn","itemId":"item","cwd":"/workspace","reason":"network","startedAtMs":13,"permissions":{"network":{"enabled":true}}}`, + assertions: func(t *testing.T, request ServerRequest) { + require.Equal(t, "/workspace", request.PermissionApproval.Cwd) + require.JSONEq(t, `{"network":{"enabled":true}}`, string(request.PermissionApproval.Requested)) + }, + }, + { + name: "user input", + method: ServerRequestUserInput, + params: `{"threadId":"thread","turnId":"turn","itemId":"item","autoResolutionMs":1000,"questions":[{"id":"choice","header":"Choice","question":"Pick","options":[{"label":"A","description":"first"}]}]}`, + assertions: func(t *testing.T, request ServerRequest) { + require.Equal(t, "choice", request.UserInput.Questions[0].ID) + require.EqualValues(t, 1000, *request.UserInput.AutoResolutionMS) + }, + }, + { + name: "mcp elicitation", + method: ServerRequestMCPElicitation, + params: `{"threadId":"thread","turnId":"turn","serverName":"docs","mode":"form","message":"Credentials","requestedSchema":{"type":"object"}}`, + assertions: func(t *testing.T, request ServerRequest) { + require.Equal(t, "docs", request.MCPElicitation.ServerName) + require.JSONEq(t, `{"type":"object"}`, string(request.MCPElicitation.RequestedSchema)) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + request, err := decodeServerRequest(string(tt.method), json.RawMessage(tt.params)) + require.NoError(t, err) + require.Equal(t, tt.method, request.Type) + require.Equal(t, "thread", request.ThreadID) + require.Equal(t, "turn", request.TurnID) + tt.assertions(t, request) + }) + } +} diff --git a/codexapp/stderr.go b/codexapp/stderr.go new file mode 100644 index 0000000..9695246 --- /dev/null +++ b/codexapp/stderr.go @@ -0,0 +1,35 @@ +package codexapp + +import "sync" + +type tailWriter struct { + mu sync.Mutex + max int + data []byte +} + +func newTailWriter(maxBytes int) *tailWriter { + return &tailWriter{max: maxBytes, data: make([]byte, 0, maxBytes)} +} + +func (w *tailWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + written := len(p) + if len(p) >= w.max { + w.data = append(w.data[:0], p[len(p)-w.max:]...) + return written, nil + } + w.data = append(w.data, p...) + if overflow := len(w.data) - w.max; overflow > 0 { + copy(w.data, w.data[overflow:]) + w.data = w.data[:w.max] + } + return written, nil +} + +func (w *tailWriter) String() string { + w.mu.Lock() + defer w.mu.Unlock() + return string(w.data) +} diff --git a/codexapp/supervisor.go b/codexapp/supervisor.go new file mode 100644 index 0000000..22e499d --- /dev/null +++ b/codexapp/supervisor.go @@ -0,0 +1,268 @@ +package codexapp + +import ( + "context" + cryptorand "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "sync" + "time" +) + +// RestartConfig controls process restart backoff. +type RestartConfig struct { + InitialDelay time.Duration + MaxDelay time.Duration + Multiplier float64 + Jitter float64 + ResetAfter time.Duration +} + +// Supervisor explicitly runs and replaces failed App Server process generations. +type Supervisor struct { + config Config + restart RestartConfig + + mu sync.Mutex + current *Client + generation int64 + changed chan struct{} + started bool + runCancel context.CancelFunc + runDone chan struct{} + runErr error +} + +// NewSupervisor validates configuration without starting background work. +func NewSupervisor(config Config, restart RestartConfig) (*Supervisor, error) { + var err error + config, err = normalizeConfig(config) + if err != nil { + return nil, err + } + if restart == (RestartConfig{}) { + restart = RestartConfig{ + InitialDelay: 250 * time.Millisecond, + MaxDelay: 5 * time.Second, + Multiplier: 2, + Jitter: 0.2, + ResetAfter: time.Minute, + } + } + if restart.InitialDelay <= 0 { + return nil, fmt.Errorf("codexapp: restart initial delay must be positive") + } + if restart.MaxDelay < restart.InitialDelay { + return nil, fmt.Errorf("codexapp: restart max delay must not be less than initial delay") + } + if restart.Multiplier < 1 { + return nil, fmt.Errorf("codexapp: restart multiplier must be at least one") + } + if restart.Jitter < 0 || restart.Jitter > 1 { + return nil, fmt.Errorf("codexapp: restart jitter must be between zero and one") + } + if restart.ResetAfter <= 0 { + return nil, fmt.Errorf("codexapp: restart reset-after must be positive") + } + return &Supervisor{ + config: config, + restart: restart, + changed: make(chan struct{}), + runDone: make(chan struct{}), + }, nil +} + +// Run owns the restart loop until ctx ends or a terminal startup error occurs. +func (s *Supervisor) Run(ctx context.Context) (runErr error) { + if ctx == nil { + return fmt.Errorf("codexapp: context is required") + } + runCtx, cancel, err := s.beginRun(ctx) + if err != nil { + return err + } + defer func() { s.finishRun(cancel, runErr) }() + + delay := s.restart.InitialDelay + for { + if runCtx.Err() != nil { + return nil + } + startedAt := time.Now() + client, err := Open(runCtx, s.config) + if err != nil { + if runCtx.Err() != nil { + return nil + } + return fmt.Errorf("codexapp: supervisor start: %w", err) + } + s.publishClient(client) + waitForClient(runCtx, client) + s.clearClient(client) + if runCtx.Err() != nil { + return nil + } + if time.Since(startedAt) >= s.restart.ResetAfter { + delay = s.restart.InitialDelay + } + if !waitForRestart(runCtx, withJitter(delay, s.restart.Jitter)) { + return nil + } + delay = s.nextDelay(delay) + } +} + +func (s *Supervisor) beginRun(ctx context.Context) (context.Context, context.CancelFunc, error) { + s.mu.Lock() + if s.started { + s.mu.Unlock() + return nil, nil, fmt.Errorf("codexapp: supervisor is already running") + } + runCtx, cancel := context.WithCancel(ctx) + s.started = true + s.runCancel = cancel + s.notifyLocked() + s.mu.Unlock() + return runCtx, cancel, nil +} + +func (s *Supervisor) finishRun(cancel context.CancelFunc, runErr error) { + cancel() + s.mu.Lock() + s.runErr = runErr + close(s.runDone) + s.notifyLocked() + s.mu.Unlock() +} + +func (s *Supervisor) publishClient(client *Client) { + s.mu.Lock() + s.generation++ + client.generation = s.generation + s.current = client + s.notifyLocked() + s.mu.Unlock() +} + +func waitForClient(runCtx context.Context, client *Client) { + select { + case <-runCtx.Done(): + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = client.Close(shutdownCtx) + shutdownCancel() + case <-client.done: + } +} + +func (s *Supervisor) clearClient(client *Client) { + s.mu.Lock() + if s.current == client { + s.current = nil + s.notifyLocked() + } + s.mu.Unlock() +} + +func waitForRestart(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return false + case <-timer.C: + return true + } +} + +func (s *Supervisor) nextDelay(delay time.Duration) time.Duration { + next := time.Duration(float64(delay) * s.restart.Multiplier) + if next > s.restart.MaxDelay { + return s.restart.MaxDelay + } + return next +} + +// Client waits for and returns the current ready process generation. +func (s *Supervisor) Client(ctx context.Context) (*Client, error) { + if ctx == nil { + return nil, fmt.Errorf("codexapp: context is required") + } + for { + s.mu.Lock() + client := s.current + changed := s.changed + started := s.started + runDone := s.runDone + runErr := s.runErr + s.mu.Unlock() + if client != nil { + select { + case <-client.done: + default: + return client, nil + } + } + if started { + select { + case <-runDone: + if runErr != nil { + return nil, runErr + } + return nil, errors.New("codexapp: supervisor stopped") + default: + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-changed: + } + } +} + +// Shutdown stops Run and closes the current client. +func (s *Supervisor) Shutdown(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("codexapp: context is required") + } + s.mu.Lock() + started := s.started + cancel := s.runCancel + client := s.current + runDone := s.runDone + s.mu.Unlock() + if !started { + return nil + } + cancel() + var closeErr error + if client != nil { + closeErr = client.Close(ctx) + } + select { + case <-runDone: + return closeErr + case <-ctx.Done(): + return errors.Join(closeErr, ctx.Err()) + } +} + +func (s *Supervisor) notifyLocked() { + close(s.changed) + s.changed = make(chan struct{}) +} + +func withJitter(delay time.Duration, fraction float64) time.Duration { + if fraction == 0 { + return delay + } + var random [8]byte + if _, err := cryptorand.Read(random[:]); err != nil { + return delay + } + ratio := float64(binary.LittleEndian.Uint64(random[:]))/float64(^uint64(0))*2 - 1 + return time.Duration(float64(delay) * (1 + ratio*fraction)) +} diff --git a/codexapp/telemetry.go b/codexapp/telemetry.go new file mode 100644 index 0000000..a06b330 --- /dev/null +++ b/codexapp/telemetry.go @@ -0,0 +1,99 @@ +package codexapp + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +const instrumentationName = "github.com/devctllabs/go-libs/codexapp" + +type instrumentation struct { + tracer trace.Tracer + propagator propagation.TextMapPropagator + requests metric.Int64Counter + duration metric.Float64Histogram +} + +func newInstrumentation(config Telemetry) (*instrumentation, error) { + tracerProvider := config.TracerProvider + if tracerProvider == nil { + tracerProvider = tracenoop.NewTracerProvider() + } + meterProvider := config.MeterProvider + if meterProvider == nil { + meterProvider = metricnoop.NewMeterProvider() + } + propagator := config.Propagator + if propagator == nil { + propagator = propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) + } + meter := meterProvider.Meter(instrumentationName) + requests, err := meter.Int64Counter("codexapp.rpc.requests") + if err != nil { + return nil, err + } + duration, err := meter.Float64Histogram("codexapp.rpc.duration", metric.WithUnit("s")) + if err != nil { + return nil, err + } + return &instrumentation{ + tracer: tracerProvider.Tracer(instrumentationName), + propagator: propagator, + requests: requests, + duration: duration, + }, nil +} + +func (i *instrumentation) startRPC(ctx context.Context, method string) (context.Context, func(error)) { + attributes := []attribute.KeyValue{ + attribute.String("rpc.system", "jsonrpc"), + attribute.String("rpc.method", method), + } + startedAt := time.Now() + ctx, span := i.tracer.Start(ctx, "codexapp."+method, + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(attributes...), + ) + return ctx, func(err error) { + status := "ok" + if err != nil { + status = "error" + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + metricAttributes := metric.WithAttributes( + attribute.String("rpc.method", method), + attribute.String("status", status), + ) + i.requests.Add(ctx, 1, metricAttributes) + i.duration.Record(ctx, time.Since(startedAt).Seconds(), metricAttributes) + span.End() + } +} + +func (i *instrumentation) inject(ctx context.Context) map[string]string { + carrier := propagation.MapCarrier{} + i.propagator.Inject(ctx, carrier) + if len(carrier) == 0 { + return nil + } + return map[string]string(carrier) +} + +func (i *instrumentation) extract(ctx context.Context, headers map[string]string) context.Context { + if len(headers) == 0 { + return ctx + } + return i.propagator.Extract(ctx, propagation.MapCarrier(headers)) +} diff --git a/codexapp/turn.go b/codexapp/turn.go new file mode 100644 index 0000000..3fbb8e3 --- /dev/null +++ b/codexapp/turn.go @@ -0,0 +1,801 @@ +package codexapp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" +) + +// ThreadSettings contains sticky settings for a new thread. +type ThreadSettings struct { + Model string + ModelProvider string + Cwd string + ApprovalPolicy ApprovalPolicy + BaseInstructions string + DeveloperInstructions string + Personality Personality +} + +type ApprovalPolicy string + +const ( + ApprovalPolicyUntrusted ApprovalPolicy = "untrusted" + ApprovalPolicyOnRequest ApprovalPolicy = "on-request" + ApprovalPolicyNever ApprovalPolicy = "never" +) + +type Personality string + +const ( + PersonalityFriendly Personality = "friendly" + PersonalityPragmatic Personality = "pragmatic" + PersonalityNone Personality = "none" +) + +type ReasoningSummary string + +const ( + ReasoningSummaryAuto ReasoningSummary = "auto" + ReasoningSummaryConcise ReasoningSummary = "concise" + ReasoningSummaryDetailed ReasoningSummary = "detailed" + ReasoningSummaryNone ReasoningSummary = "none" +) + +// StartThreadRequest creates a thread with the supplied settings. +type StartThreadRequest struct { + Settings ThreadSettings + Permissions Permissions +} + +// ResumeThreadRequest loads an existing thread and optionally reapplies sticky settings. +type ResumeThreadRequest struct { + ThreadID string + Settings ThreadSettings + Permissions Permissions +} + +// ReadThreadRequest reads persisted thread state without resuming it. +type ReadThreadRequest struct { + ThreadID string + IncludeTurns bool +} + +// Permissions defines exact local roots and network access for a generated beta profile. +type Permissions struct { + ReadRoots []string + WriteRoots []string + NetworkEnabled bool +} + +// Thread is the stable thread projection returned by this package. +type Thread struct { + ID string + Model string + Preview string + CreatedAt int64 + UpdatedAt int64 + Turns []Turn +} + +// Input is one user input item. Construct it with Text and the other typed constructors. +type Input struct { + kind string + text string + url string + path string + name string +} + +const ( + inputTypeText = "text" + inputTypeImage = "image" + inputTypeLocalImage = "localImage" + inputTypeAudio = "audio" + inputTypeLocalAudio = "localAudio" + inputTypeSkill = "skill" + inputTypeMention = "mention" +) + +// Text constructs a text input item. +func Text(value string) Input { + return Input{kind: inputTypeText, text: value} +} + +// Image constructs a remote image input. +func Image(url string) Input { return Input{kind: inputTypeImage, url: url} } + +// LocalImage constructs a local image input. +func LocalImage(path string) Input { return Input{kind: inputTypeLocalImage, path: path} } + +// Audio constructs a remote audio input. +func Audio(url string) Input { return Input{kind: inputTypeAudio, url: url} } + +// LocalAudio constructs a local audio input. +func LocalAudio(path string) Input { return Input{kind: inputTypeLocalAudio, path: path} } + +// Skill constructs an explicit skill reference. +func Skill(name string, path string) Input { + return Input{kind: inputTypeSkill, name: name, path: path} +} + +// Mention constructs a named file or capability mention. +func Mention(name string, path string) Input { + return Input{kind: inputTypeMention, name: name, path: path} +} + +// StartTurnRequest starts a turn in an existing thread. +type StartTurnRequest struct { + ThreadID string + Input []Input + Permissions Permissions + Model string + Cwd string + Effort string + Summary ReasoningSummary + Personality Personality + OutputSchema json.RawMessage +} + +// InterruptTurnRequest identifies an active turn to interrupt. +type InterruptTurnRequest struct { + ThreadID string + TurnID string +} + +// TurnStatus is the App Server lifecycle status of a turn. +type TurnStatus string + +const ( + // TurnStatusInProgress indicates that Codex is still working. + TurnStatusInProgress TurnStatus = "inProgress" + // TurnStatusCompleted indicates successful completion. + TurnStatusCompleted TurnStatus = "completed" + // TurnStatusFailed indicates terminal failure. + TurnStatusFailed TurnStatus = "failed" + // TurnStatusInterrupted indicates caller interruption. + TurnStatusInterrupted TurnStatus = "interrupted" +) + +// Turn is the stable turn projection returned by this package. +type Turn struct { + ID string + Status TurnStatus +} + +// TurnResult is the terminal value returned by TurnHandle.Wait. +type TurnResult struct { + ThreadID string + Turn Turn +} + +// TurnHandle owns the event stream and terminal result for one turn. +type TurnHandle struct { + client *Client + threadID string + stream *EventStream + done chan struct{} + doneOnce sync.Once + mu sync.Mutex + turn Turn + result TurnResult + err error + terminal bool +} + +// Events returns the bounded stream of notifications correlated to the turn. +func (h *TurnHandle) Events() *EventStream { + return h.stream +} + +// Wait blocks until the turn completes or ctx is canceled. +func (h *TurnHandle) Wait(ctx context.Context) (TurnResult, error) { + if ctx == nil { + return TurnResult{}, fmt.Errorf("codexapp: context is required") + } + select { + case <-ctx.Done(): + return TurnResult{}, ctx.Err() + case <-h.done: + h.mu.Lock() + defer h.mu.Unlock() + return h.result, h.err + } +} + +// Interrupt requests interruption of this turn. +func (h *TurnHandle) Interrupt(ctx context.Context) error { + h.mu.Lock() + turnID := h.turn.ID + h.mu.Unlock() + if turnID == "" { + return fmt.Errorf("codexapp: turn id is not available") + } + return h.client.InterruptTurn(ctx, InterruptTurnRequest{ThreadID: h.threadID, TurnID: turnID}) +} + +// StartThread starts a new Codex thread. +func (c *Client) StartThread(ctx context.Context, request StartThreadRequest) (Thread, error) { + params := threadStartParams{} + var boundProfile *normalizedPermissionProfile + if request.Settings.Model != "" { + params.Model = &request.Settings.Model + } + if request.Settings.Cwd != "" { + params.Cwd = &request.Settings.Cwd + } + applyThreadSettings(¶ms, request.Settings) + if !request.Permissions.empty() { + if !c.capabilities[CapabilityPermissionProfiles] { + return Thread{}, fmt.Errorf("codexapp: permission profiles capability was not requested") + } + profile, err := buildPermissionProfile(request.Permissions) + if err != nil { + return Thread{}, err + } + params.Permissions = &profile.ID + params.RuntimeWorkspaceRoots = profile.WriteRoots + params.Config = map[string]any{ + "permissions": map[string]permissionProfileConfig{profile.ID: profile.Config}, + } + boundProfile = &profile + } + var response threadStartResponse + if err := c.call(ctx, "thread/start", params, &response); err != nil { + return Thread{}, fmt.Errorf("codexapp: start thread: %w", err) + } + thread := Thread{ + ID: response.Thread.ID, + Model: response.Model, + Preview: response.Thread.Preview, + CreatedAt: response.Thread.CreatedAt, + UpdatedAt: response.Thread.UpdatedAt, + } + if boundProfile != nil { + c.turnsMu.Lock() + c.profiles[thread.ID] = *boundProfile + c.turnsMu.Unlock() + } + return thread, nil +} + +// ResumeThread loads an existing thread into the current App Server session. +func (c *Client) ResumeThread(ctx context.Context, request ResumeThreadRequest) (Thread, error) { + if strings.TrimSpace(request.ThreadID) == "" { + return Thread{}, fmt.Errorf("codexapp: thread id is required") + } + params := threadResumeParams{ThreadID: request.ThreadID} + if request.Settings.Model != "" { + params.Model = &request.Settings.Model + } + if request.Settings.Cwd != "" { + params.Cwd = &request.Settings.Cwd + } + var response threadStartResponse + if err := c.call(ctx, "thread/resume", params, &response); err != nil { + return Thread{}, fmt.Errorf("codexapp: resume thread: %w", err) + } + return mapThread(response.Thread, response.Model), nil +} + +// ReadThread reads thread state without making it active. +func (c *Client) ReadThread(ctx context.Context, request ReadThreadRequest) (Thread, error) { + if strings.TrimSpace(request.ThreadID) == "" { + return Thread{}, fmt.Errorf("codexapp: thread id is required") + } + var response struct { + Thread threadWire `json:"thread"` + } + if err := c.call(ctx, "thread/read", threadReadParams(request), &response); err != nil { + return Thread{}, fmt.Errorf("codexapp: read thread: %w", err) + } + return mapThread(response.Thread, ""), nil +} + +// InterruptTurn requests interruption of an active turn. +func (c *Client) InterruptTurn(ctx context.Context, request InterruptTurnRequest) error { + if strings.TrimSpace(request.ThreadID) == "" || strings.TrimSpace(request.TurnID) == "" { + return fmt.Errorf("codexapp: thread id and turn id are required") + } + if err := c.call(ctx, "turn/interrupt", turnInterruptParams(request), nil); err != nil { + return fmt.Errorf("codexapp: interrupt turn: %w", err) + } + return nil +} + +// StartTurn starts a turn and returns its independently consumable handle. +func (c *Client) StartTurn(ctx context.Context, request StartTurnRequest) (*TurnHandle, error) { + if strings.TrimSpace(request.ThreadID) == "" { + return nil, fmt.Errorf("codexapp: thread id is required") + } + params, err := c.buildTurnStartParams(request) + if err != nil { + return nil, err + } + handle, err := c.beginTurnStart(request.ThreadID) + if err != nil { + return nil, err + } + + var response turnStartResponse + if err := c.call(ctx, "turn/start", params, &response); err != nil { + c.abortTurnStart(request.ThreadID) + return nil, fmt.Errorf("codexapp: start turn: %w", err) + } + c.finishTurnStart(request.ThreadID, handle, response.Turn) + return handle, nil +} + +func (c *Client) buildTurnStartParams(request StartTurnRequest) (turnStartParams, error) { + inputs := make([]inputWire, 0, len(request.Input)) + for _, input := range request.Input { + wire, err := mapInput(input) + if err != nil { + return turnStartParams{}, err + } + inputs = append(inputs, wire) + } + params := turnStartParams{ThreadID: request.ThreadID, Input: inputs} + params.Model = optionalString(request.Model) + params.Cwd = optionalString(request.Cwd) + params.Effort = optionalString(request.Effort) + params.Summary = optionalReasoningSummary(request.Summary) + params.Personality = optionalPersonality(request.Personality) + if len(request.OutputSchema) > 0 { + params.OutputSchema = append(json.RawMessage(nil), request.OutputSchema...) + } + if err := c.bindTurnPermissions(request, ¶ms); err != nil { + return turnStartParams{}, err + } + return params, nil +} + +func (c *Client) bindTurnPermissions(request StartTurnRequest, params *turnStartParams) error { + if request.Permissions.empty() { + return nil + } + profile, err := buildPermissionProfile(request.Permissions) + if err != nil { + return err + } + c.turnsMu.Lock() + bound, exists := c.profiles[request.ThreadID] + c.turnsMu.Unlock() + if !exists || bound.ID != profile.ID { + return fmt.Errorf("%w %s", ErrPermissionProfileMismatch, request.ThreadID) + } + params.Permissions = &bound.ID + params.RuntimeWorkspaceRoots = append([]string(nil), bound.WriteRoots...) + return nil +} + +func (c *Client) beginTurnStart(threadID string) (*TurnHandle, error) { + handle := &TurnHandle{ + client: c, + threadID: threadID, + stream: newEventStream(c.eventBuffer), + done: make(chan struct{}), + } + c.turnsMu.Lock() + if _, exists := c.starting[threadID]; exists { + c.turnsMu.Unlock() + return nil, fmt.Errorf("%w for thread %s", ErrTurnStartInProgress, threadID) + } + c.starting[threadID] = handle + c.turnsMu.Unlock() + return handle, nil +} + +func (c *Client) abortTurnStart(threadID string) { + c.turnsMu.Lock() + delete(c.starting, threadID) + c.turnsMu.Unlock() +} + +func (c *Client) finishTurnStart(threadID string, handle *TurnHandle, turn turnWire) { + handle.bind(turn) + c.turnsMu.Lock() + delete(c.starting, threadID) + handle.mu.Lock() + terminal := handle.terminal + turnID := handle.turn.ID + handle.mu.Unlock() + if !terminal { + c.turns[turnID] = handle + } + c.turnsMu.Unlock() +} + +type threadStartParams struct { + Model *string `json:"model,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Config map[string]any `json:"config,omitempty"` + Permissions *string `json:"permissions,omitempty"` + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots,omitempty"` + ModelProvider *string `json:"modelProvider,omitempty"` + ApprovalPolicy *ApprovalPolicy `json:"approvalPolicy,omitempty"` + BaseInstructions *string `json:"baseInstructions,omitempty"` + DeveloperInstructions *string `json:"developerInstructions,omitempty"` + Personality *Personality `json:"personality,omitempty"` +} + +type threadStartResponse struct { + Model string `json:"model"` + Thread threadWire `json:"thread"` +} + +type threadWire struct { + ID string `json:"id"` + Preview string `json:"preview"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Turns []turnWire `json:"turns"` +} + +type threadResumeParams struct { + ThreadID string `json:"threadId"` + Model *string `json:"model,omitempty"` + Cwd *string `json:"cwd,omitempty"` +} + +type threadReadParams struct { + ThreadID string `json:"threadId"` + IncludeTurns bool `json:"includeTurns"` +} + +type turnInterruptParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +type inputWire struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + URL *string `json:"url,omitempty"` + Path *string `json:"path,omitempty"` + Name *string `json:"name,omitempty"` +} + +func mapInput(input Input) (inputWire, error) { + wire := inputWire{Type: input.kind} + switch input.kind { + case inputTypeText: + wire.Text = &input.text + case inputTypeImage, inputTypeAudio: + wire.URL = &input.url + case inputTypeLocalImage, inputTypeLocalAudio: + wire.Path = &input.path + case inputTypeSkill, inputTypeMention: + wire.Name = &input.name + wire.Path = &input.path + default: + return inputWire{}, fmt.Errorf("codexapp: unsupported input type %q", input.kind) + } + return wire, nil +} + +type turnStartParams struct { + ThreadID string `json:"threadId"` + Input []inputWire `json:"input"` + Permissions *string `json:"permissions,omitempty"` + RuntimeWorkspaceRoots []string `json:"runtimeWorkspaceRoots,omitempty"` + Model *string `json:"model,omitempty"` + Cwd *string `json:"cwd,omitempty"` + Effort *string `json:"effort,omitempty"` + Summary *ReasoningSummary `json:"summary,omitempty"` + Personality *Personality `json:"personality,omitempty"` + OutputSchema json.RawMessage `json:"outputSchema,omitempty"` +} + +func applyThreadSettings(params *threadStartParams, settings ThreadSettings) { + params.ModelProvider = optionalString(settings.ModelProvider) + params.ApprovalPolicy = optionalApprovalPolicy(settings.ApprovalPolicy) + params.BaseInstructions = optionalString(settings.BaseInstructions) + params.DeveloperInstructions = optionalString(settings.DeveloperInstructions) + params.Personality = optionalPersonality(settings.Personality) +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func optionalApprovalPolicy(value ApprovalPolicy) *ApprovalPolicy { + if value == "" { + return nil + } + return &value +} + +func optionalPersonality(value Personality) *Personality { + if value == "" { + return nil + } + return &value +} + +func optionalReasoningSummary(value ReasoningSummary) *ReasoningSummary { + if value == "" { + return nil + } + return &value +} + +type turnStartResponse struct { + Turn turnWire `json:"turn"` +} + +type turnWire struct { + ID string `json:"id"` + Status TurnStatus `json:"status"` +} + +func mapThread(raw threadWire, model string) Thread { + turns := make([]Turn, 0, len(raw.Turns)) + for _, turn := range raw.Turns { + turns = append(turns, Turn(turn)) + } + return Thread{ + ID: raw.ID, Model: model, Preview: raw.Preview, + CreatedAt: raw.CreatedAt, UpdatedAt: raw.UpdatedAt, Turns: turns, + } +} + +func (h *TurnHandle) bind(turn turnWire) { + h.mu.Lock() + defer h.mu.Unlock() + if h.turn.ID == "" { + h.turn = Turn(turn) + } +} + +func (h *TurnHandle) complete(turn Turn) { + h.mu.Lock() + if h.terminal { + h.mu.Unlock() + return + } + h.turn = turn + h.result = TurnResult{ThreadID: h.threadID, Turn: turn} + h.terminal = true + h.mu.Unlock() + h.stream.finish(nil) + h.doneOnce.Do(func() { close(h.done) }) +} + +func (h *TurnHandle) fail(err error) { + h.mu.Lock() + if h.terminal { + h.mu.Unlock() + return + } + h.err = err + h.terminal = true + h.mu.Unlock() + h.stream.finish(err) + h.doneOnce.Do(func() { close(h.done) }) +} + +func (c *Client) failTurns(cause error) { + sessionErr := &SessionLostError{Generation: c.generation, Cause: cause} + c.turnsMu.Lock() + unique := make(map[*TurnHandle]struct{}, len(c.turns)+len(c.starting)) + for _, handle := range c.turns { + unique[handle] = struct{}{} + } + for _, handle := range c.starting { + unique[handle] = struct{}{} + } + c.turns = make(map[string]*TurnHandle) + c.starting = make(map[string]*TurnHandle) + c.turnsMu.Unlock() + for handle := range unique { + handle.fail(sessionErr) + } +} + +func (c *Client) dispatchNotification(method string, params json.RawMessage) { + switch method { + case string(EventAgentMessageDelta): + c.dispatchAgentMessageDelta(params) + case string(EventTurnCompleted): + c.dispatchTurnCompleted(params) + case string(EventCommandOutputDelta), string(EventPlanDelta), string(EventReasoningSummaryDelta), string(EventReasoningTextDelta): + c.dispatchDelta(EventType(method), params) + case string(EventTurnDiffUpdated): + c.dispatchTurnDiffUpdated(params) + case string(EventTurnPlanUpdated): + c.dispatchTurnPlanUpdated(params) + case string(EventItemStarted), string(EventItemCompleted): + c.dispatchItem(EventType(method), params) + case string(EventError): + c.dispatchError(params) + case string(EventWarning): + c.dispatchWarning(params) + case string(EventTokenUsageUpdated): + c.dispatchTokenUsageUpdated(params) + } +} + +func (c *Client) dispatchAgentMessageDelta(params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Delta string `json:"delta"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + handle := c.findTurn(notification.ThreadID, notification.TurnID) + if handle == nil { + return + } + handle.bind(turnWire{ID: notification.TurnID, Status: TurnStatusInProgress}) + _ = handle.stream.push(Event{ + Type: EventAgentMessageDelta, + ThreadID: notification.ThreadID, + TurnID: notification.TurnID, + TextDelta: &TextDelta{Text: notification.Delta}, + }) +} + +func (c *Client) dispatchTurnCompleted(params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + Turn turnWire `json:"turn"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + handle := c.findTurn(notification.ThreadID, notification.Turn.ID) + if handle == nil { + return + } + turn := Turn{ID: notification.Turn.ID, Status: notification.Turn.Status} + _ = handle.stream.push(Event{ + Type: EventTurnCompleted, + ThreadID: notification.ThreadID, + TurnID: turn.ID, + Turn: &turn, + }) + handle.complete(turn) + c.turnsMu.Lock() + delete(c.turns, turn.ID) + c.turnsMu.Unlock() +} + +func (c *Client) dispatchDelta(eventType EventType, params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Delta string `json:"delta"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + handle := c.findTurn(notification.ThreadID, notification.TurnID) + if handle == nil { + return + } + event := Event{Type: eventType, ThreadID: notification.ThreadID, TurnID: notification.TurnID} + switch eventType { + case EventCommandOutputDelta: + event.CommandOutput = &CommandOutputDelta{Text: notification.Delta} + case EventPlanDelta: + event.PlanDelta = &PlanDelta{Text: notification.Delta} + default: + event.TextDelta = &TextDelta{Text: notification.Delta} + } + _ = handle.stream.push(event) +} + +func (c *Client) dispatchTurnDiffUpdated(params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Diff string `json:"diff"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + if handle := c.findTurn(notification.ThreadID, notification.TurnID); handle != nil { + _ = handle.stream.push(Event{Type: EventTurnDiffUpdated, ThreadID: notification.ThreadID, TurnID: notification.TurnID, Diff: &DiffUpdate{Diff: notification.Diff}}) + } +} + +func (c *Client) dispatchTurnPlanUpdated(params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Explanation string `json:"explanation"` + Plan []PlanStep `json:"plan"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + if handle := c.findTurn(notification.ThreadID, notification.TurnID); handle != nil { + _ = handle.stream.push(Event{Type: EventTurnPlanUpdated, ThreadID: notification.ThreadID, TurnID: notification.TurnID, Plan: &PlanUpdate{Explanation: notification.Explanation, Steps: notification.Plan}}) + } +} + +func (c *Client) dispatchItem(eventType EventType, params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item json.RawMessage `json:"item"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + var identity struct{ ID, Type string } + _ = json.Unmarshal(notification.Item, &identity) + if handle := c.findTurn(notification.ThreadID, notification.TurnID); handle != nil { + _ = handle.stream.push(Event{Type: eventType, ThreadID: notification.ThreadID, TurnID: notification.TurnID, Item: &Item{ID: identity.ID, Type: identity.Type, Raw: append(json.RawMessage(nil), notification.Item...)}}) + } +} + +func (c *Client) dispatchError(params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + WillRetry bool `json:"willRetry"` + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + if handle := c.findTurn(notification.ThreadID, notification.TurnID); handle != nil { + _ = handle.stream.push(Event{Type: EventError, ThreadID: notification.ThreadID, TurnID: notification.TurnID, Failure: &FailureEvent{Message: notification.Error.Message, WillRetry: notification.WillRetry}}) + } +} + +func (c *Client) dispatchWarning(params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Message string `json:"message"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + if handle := c.findTurn(notification.ThreadID, notification.TurnID); handle != nil { + _ = handle.stream.push(Event{Type: EventWarning, ThreadID: notification.ThreadID, TurnID: notification.TurnID, Warning: &WarningEvent{Message: notification.Message}}) + } +} + +func (c *Client) dispatchTokenUsageUpdated(params json.RawMessage) { + var notification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Usage json.RawMessage `json:"tokenUsage"` + } + if json.Unmarshal(params, ¬ification) != nil { + return + } + if handle := c.findTurn(notification.ThreadID, notification.TurnID); handle != nil { + _ = handle.stream.push(Event{Type: EventTokenUsageUpdated, ThreadID: notification.ThreadID, TurnID: notification.TurnID, TokenUsage: &TokenUsageEvent{Raw: append(json.RawMessage(nil), notification.Usage...)}}) + } +} + +func (c *Client) findTurn(threadID string, turnID string) *TurnHandle { + c.turnsMu.Lock() + defer c.turnsMu.Unlock() + if handle := c.turns[turnID]; handle != nil { + return handle + } + if handle := c.starting[threadID]; handle != nil { + return handle + } + for _, handle := range c.turns { + if handle.threadID == threadID { + return handle + } + } + return nil +} diff --git a/codexapp/turn_test.go b/codexapp/turn_test.go new file mode 100644 index 0000000..77da8e1 --- /dev/null +++ b/codexapp/turn_test.go @@ -0,0 +1,38 @@ +package codexapp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStartTurnClassifiesConcurrentStart(t *testing.T) { + t.Parallel() + client := &Client{ + starting: map[string]*TurnHandle{"thread": {}}, + eventBuffer: 1, + } + _, err := client.StartTurn(context.Background(), StartTurnRequest{ + ThreadID: "thread", + Input: []Input{Text("hello")}, + }) + require.ErrorIs(t, err, ErrTurnStartInProgress) +} + +func TestStartTurnRejectsDifferentPermissionProfile(t *testing.T) { + t.Parallel() + bound, err := buildPermissionProfile(Permissions{ReadRoots: []string{"/workspace"}}) + require.NoError(t, err) + client := &Client{ + starting: map[string]*TurnHandle{}, + profiles: map[string]normalizedPermissionProfile{"thread": bound}, + eventBuffer: 1, + } + _, err = client.StartTurn(context.Background(), StartTurnRequest{ + ThreadID: "thread", + Input: []Input{Text("hello")}, + Permissions: Permissions{WriteRoots: []string{"/workspace"}}, + }) + require.ErrorIs(t, err, ErrPermissionProfileMismatch) +} diff --git a/config/defaults.go b/config/defaults.go new file mode 100644 index 0000000..0276525 --- /dev/null +++ b/config/defaults.go @@ -0,0 +1,27 @@ +package config + +import ( + "context" + "fmt" + + "github.com/creasty/defaults" +) + +type defaultsLoader struct{} + +// Defaults creates a loader for "default" struct tags. Existing non-zero values +// are preserved according to github.com/creasty/defaults semantics. +func Defaults() Loader { + return defaultsLoader{} +} + +func (defaultsLoader) Load(ctx context.Context, target any) error { + if err := ctx.Err(); err != nil { + return err + } + if err := defaults.Set(target); err != nil { + return fmt.Errorf("config: load defaults: %w", err) + } + + return nil +} diff --git a/config/defaults_test.go b/config/defaults_test.go new file mode 100644 index 0000000..b423390 --- /dev/null +++ b/config/defaults_test.go @@ -0,0 +1,38 @@ +package config_test + +import ( + "context" + "testing" + + "github.com/devctllabs/go-libs/config" + "github.com/stretchr/testify/require" +) + +func TestDefaultsSetsZeroValuesAndPreservesExistingValues(t *testing.T) { + t.Parallel() + + target := struct { + Host string `default:"localhost"` + Port int `default:"8080"` + }{ + Port: 9090, + } + + err := config.Defaults().Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "localhost", target.Host) + require.Equal(t, 9090, target.Port) +} + +func TestDefaultsReturnsMalformedTagError(t *testing.T) { + t.Parallel() + + target := struct { + Values []string `default:"not-json"` + }{} + + err := config.Defaults().Load(context.Background(), &target) + + require.Error(t, err) +} diff --git a/config/doc.go b/config/doc.go new file mode 100644 index 0000000..8adb3ea --- /dev/null +++ b/config/doc.go @@ -0,0 +1,39 @@ +// Package config loads configuration into caller-owned values from ordered, +// composable sources. +// +// # Loading and precedence +// +// Pass a non-nil pointer to the application config value to Loader.Load. Chain +// applies loaders in the supplied order, so callers express increasing +// precedence directly: defaults, files, dotenv data, process environment, then +// explicit runtime overrides. Loading is fail-fast, and the target may be +// partially updated when a loader returns an error; load into a temporary value +// and validate it before publishing it to the application. +// +// Defaults reads "default" tags. YAML and TOML use their corresponding tags and +// overlay only fields present in the input; maps and slices are replaced as +// whole values. DotEnv, EnvMap, and OSEnv decode "env" tags. DotEnv does not +// modify the process environment, EnvMap owns a snapshot of the supplied map, +// and OSEnv reads the process environment when Load is called. +// +// # Inputs and custom sources +// +// Path and FromFS provide inputs backed by an operating system path or fs.FS. +// Optional suppresses only errors that match fs.ErrNotExist, which makes it +// suitable for optional config files without hiding parse or permission errors. +// +// Implement Input when an existing format must read from another location. +// Implement Loader for a new format or source, or implement TypedLoader and use +// Typed when the source is specific to one application config type. An S3, +// Vault, CLI, or other source is another loader placed at its chosen precedence; +// it does not require changes to Chain. +// +// CLI adapters should represent presence explicitly, commonly with pointer +// fields, so an absent option remains distinguishable from explicit false, zero, +// an empty string, or a nil-capable value. The application composition root +// should load and validate the final value once before constructing runtime +// resources. +// +// Generated gomock implementations of Input and Loader are available in the +// config/mocks subpackage for direct consumers of those interfaces. +package config diff --git a/config/dotenv.go b/config/dotenv.go new file mode 100644 index 0000000..b194fe5 --- /dev/null +++ b/config/dotenv.go @@ -0,0 +1,42 @@ +package config + +import ( + "context" + "errors" + "fmt" + + "github.com/joho/godotenv" +) + +type dotEnvLoader struct { + input Input +} + +// DotEnv creates a loader that parses dotenv data from input and decodes it +// through "env" struct tags. It does not modify or otherwise read the process +// environment. +func DotEnv(input Input) Loader { + return dotEnvLoader{input: input} +} + +func (l dotEnvLoader) Load(ctx context.Context, target any) error { + if isNilValue(l.input) { + return errors.New("config: nil dotenv input") + } + + reader, err := l.input.Open(ctx) + if err != nil { + return fmt.Errorf("config: open dotenv input: %w", err) + } + if reader == nil { + return errors.New("config: dotenv input returned a nil reader") + } + defer func() { _ = reader.Close() }() + + values, err := godotenv.Parse(reader) + if err != nil { + return fmt.Errorf("config: parse dotenv: %w", err) + } + + return loadEnvMap(ctx, target, values) +} diff --git a/config/dotenv_test.go b/config/dotenv_test.go new file mode 100644 index 0000000..1b2ab27 --- /dev/null +++ b/config/dotenv_test.go @@ -0,0 +1,82 @@ +package config_test + +import ( + "context" + "io/fs" + "os" + "testing" + "testing/fstest" + + "github.com/devctllabs/go-libs/config" + "github.com/stretchr/testify/require" +) + +func TestDotEnvLoadsWithoutMutatingProcessEnvironment(t *testing.T) { + t.Setenv("TEST_APP_HOST", "from-os") + filesystem := fstest.MapFS{ + "app.env": {Data: []byte("TEST_APP_HOST=from-file\nTEST_APP_PORT=8080\n")}, + } + var target envConfig + + err := config.DotEnv(config.FromFS(filesystem, "app.env")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "from-file", target.Host) + require.Equal(t, 8080, target.Port) + require.Equal(t, "from-os", os.Getenv("TEST_APP_HOST")) +} + +func TestMultipleDotEnvFilesAndEnvMapUseChainOrder(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "base.env": {Data: []byte("TEST_APP_HOST=base\nTEST_APP_PORT=1000\n")}, + "local.env": {Data: []byte("TEST_APP_PORT=2000\n")}, + } + var target envConfig + loader := config.Chain( + config.DotEnv(config.FromFS(filesystem, "base.env")), + config.DotEnv(config.FromFS(filesystem, "local.env")), + config.EnvMap(map[string]string{"TEST_APP_PORT": "3000"}), + ) + + err := loader.Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "base", target.Host) + require.Equal(t, 3000, target.Port) +} + +func TestOptionalDotEnvIgnoresMissingInput(t *testing.T) { + t.Parallel() + + var target envConfig + loader := config.Optional(config.DotEnv(config.FromFS(fstest.MapFS{}, "missing.env"))) + + err := loader.Load(context.Background(), &target) + + require.NoError(t, err) +} + +func TestDotEnvPreservesMissingInputError(t *testing.T) { + t.Parallel() + + var target envConfig + + err := config.DotEnv(config.FromFS(fstest.MapFS{}, "missing.env")).Load(context.Background(), &target) + + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestDotEnvReturnsParseError(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "broken.env": {Data: []byte("BROKEN='unterminated\n")}, + } + var target envConfig + + err := config.DotEnv(config.FromFS(filesystem, "broken.env")).Load(context.Background(), &target) + + require.Error(t, err) +} diff --git a/config/env.go b/config/env.go new file mode 100644 index 0000000..6701861 --- /dev/null +++ b/config/env.go @@ -0,0 +1,51 @@ +package config + +import ( + "context" + "fmt" + "os" + + "github.com/caarlos0/env/v11" +) + +type envMapLoader struct { + values map[string]string +} + +type osEnvLoader struct{} + +// EnvMap creates a loader that decodes "env" struct tags from a copied snapshot +// of values. It never reads the process environment, including when values is +// nil. +func EnvMap(values map[string]string) Loader { + snapshot := make(map[string]string, len(values)) + for key, value := range values { + snapshot[key] = value + } + return envMapLoader{values: snapshot} +} + +// OSEnv creates a loader that reads the process environment at Load time and +// decodes values through "env" struct tags. +func OSEnv() Loader { + return osEnvLoader{} +} + +func (l envMapLoader) Load(ctx context.Context, target any) error { + return loadEnvMap(ctx, target, l.values) +} + +func (osEnvLoader) Load(ctx context.Context, target any) error { + return loadEnvMap(ctx, target, env.ToMap(os.Environ())) +} + +func loadEnvMap(ctx context.Context, target any, values map[string]string) error { + if err := ctx.Err(); err != nil { + return err + } + if err := env.ParseWithOptions(target, env.Options{Environment: values}); err != nil { + return fmt.Errorf("config: load environment: %w", err) + } + + return nil +} diff --git a/config/env_test.go b/config/env_test.go new file mode 100644 index 0000000..a6d9874 --- /dev/null +++ b/config/env_test.go @@ -0,0 +1,81 @@ +package config_test + +import ( + "context" + "testing" + "time" + + "github.com/devctllabs/go-libs/config" + "github.com/stretchr/testify/require" +) + +type envConfig struct { + Host string `env:"TEST_APP_HOST"` + Port int `env:"TEST_APP_PORT"` + Timeout time.Duration `env:"TEST_APP_TIMEOUT"` + Labels []string `env:"TEST_APP_LABELS"` +} + +func TestEnvMapDecodesExplicitTags(t *testing.T) { + t.Parallel() + + target := envConfig{Host: "existing"} + loader := config.EnvMap(map[string]string{ + "TEST_APP_PORT": "8080", + "TEST_APP_TIMEOUT": "5s", + "TEST_APP_LABELS": "one,two", + }) + + err := loader.Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "existing", target.Host) + require.Equal(t, 8080, target.Port) + require.Equal(t, 5*time.Second, target.Timeout) + require.Equal(t, []string{"one", "two"}, target.Labels) +} + +func TestEnvMapTakesSnapshot(t *testing.T) { + t.Parallel() + + values := map[string]string{"TEST_APP_HOST": "before"} + loader := config.EnvMap(values) + values["TEST_APP_HOST"] = "after" + var target envConfig + + err := loader.Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "before", target.Host) +} + +func TestEnvMapNilDoesNotReadOSEnvironment(t *testing.T) { + t.Setenv("TEST_APP_HOST", "from-os") + target := envConfig{Host: "existing"} + + err := config.EnvMap(nil).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "existing", target.Host) +} + +func TestOSEnvReadsEnvironmentAtLoadTime(t *testing.T) { + loader := config.OSEnv() + t.Setenv("TEST_APP_HOST", "from-os") + var target envConfig + + err := loader.Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "from-os", target.Host) +} + +func TestEnvMapReturnsConversionError(t *testing.T) { + t.Parallel() + + var target envConfig + + err := config.EnvMap(map[string]string{"TEST_APP_PORT": "invalid"}).Load(context.Background(), &target) + + require.Error(t, err) +} diff --git a/config/example_test.go b/config/example_test.go new file mode 100644 index 0000000..bbd6bf6 --- /dev/null +++ b/config/example_test.go @@ -0,0 +1,71 @@ +package config_test + +import ( + "context" + "log" + "os" + "testing" + "testing/fstest" + + "github.com/devctllabs/go-libs/config" + "github.com/stretchr/testify/require" +) + +type exampleConfig struct { + Host string `default:"default" yaml:"host" env:"EXAMPLE_HOST"` + Port int `default:"1000" yaml:"port" env:"EXAMPLE_PORT"` + Debug bool `default:"true" yaml:"debug" env:"EXAMPLE_DEBUG"` +} + +type exampleCLILoader struct { + port *int + debug *bool +} + +func (l exampleCLILoader) Load(_ context.Context, target *exampleConfig) error { + if l.port != nil { + target.Port = *l.port + } + if l.debug != nil { + target.Debug = *l.debug + } + return nil +} + +func TestTypedCLIOverridePreservesAbsentAndAppliesExplicitZero(t *testing.T) { + t.Parallel() + + target := exampleConfig{Port: 3000, Debug: true} + port := 0 + + err := config.Typed(exampleCLILoader{port: &port}).Load(context.Background(), &target) + + require.NoError(t, err) + require.Zero(t, target.Port) + require.True(t, target.Debug) +} + +func ExampleChain() { + filesystem := fstest.MapFS{ + "config.yaml": {Data: []byte("host: yaml\nport: 2000\n")}, + "local.env": {Data: []byte("EXAMPLE_PORT=3000\n")}, + } + cliPort := 4000 + cliDebug := false + var target exampleConfig + loader := config.Chain( + config.Defaults(), + config.YAML(config.FromFS(filesystem, "config.yaml")), + config.DotEnv(config.FromFS(filesystem, "local.env")), + config.EnvMap(map[string]string{"EXAMPLE_HOST": "environment"}), + config.Typed(exampleCLILoader{port: &cliPort, debug: &cliDebug}), + ) + + if err := loader.Load(context.Background(), &target); err != nil { + log.Fatal(err) + } + + logger := log.New(os.Stdout, "", 0) + logger.Printf("host=%s port=%d debug=%t", target.Host, target.Port, target.Debug) + // Output: host=environment port=4000 debug=false +} diff --git a/config/go.mod b/config/go.mod new file mode 100644 index 0000000..69c69e0 --- /dev/null +++ b/config/go.mod @@ -0,0 +1,24 @@ +module github.com/devctllabs/go-libs/config + +go 1.25.0 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/creasty/defaults v1.8.0 + github.com/joho/godotenv v1.5.1 + github.com/pelletier/go-toml/v2 v2.4.3 + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 + go.yaml.in/yaml/v3 v3.0.5 +) + +tool go.uber.org/mock/mockgen + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/config/go.sum b/config/go.sum new file mode 100644 index 0000000..fc9eebf --- /dev/null +++ b/config/go.sum @@ -0,0 +1,30 @@ +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/config/input.go b/config/input.go new file mode 100644 index 0000000..c3c4c16 --- /dev/null +++ b/config/input.go @@ -0,0 +1,81 @@ +package config + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" +) + +// Input opens configuration data for a loader. Each call must return an +// independent stream when concurrent or repeated loading is supported. +type Input interface { + // Open returns a new readable stream. The calling loader closes the stream. + // An absent resource should return an error that matches fs.ErrNotExist so it + // can be composed with Optional. + Open(ctx context.Context) (io.ReadCloser, error) +} + +type pathInput struct { + path string +} + +type fsInput struct { + fsys fs.FS + name string +} + +// Path creates an Input backed by path in the operating system filesystem. The +// path is opened when Input.Open is called. +func Path(path string) Input { + return pathInput{path: path} +} + +// FromFS creates an Input backed by name in fsys. Name must satisfy +// fs.ValidPath. The file is opened when Input.Open is called. +func FromFS(fsys fs.FS, name string) Input { + return fsInput{fsys: fsys, name: name} +} + +func (i pathInput) Open(ctx context.Context) (io.ReadCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + return os.Open(i.path) +} + +func (i fsInput) Open(ctx context.Context) (io.ReadCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if i.fsys == nil { + return nil, errors.New("config: nil filesystem") + } + + return i.fsys.Open(i.name) +} + +func readInput(ctx context.Context, input Input, kind string) ([]byte, error) { + if isNilValue(input) { + return nil, fmt.Errorf("config: nil %s input", kind) + } + + reader, err := input.Open(ctx) + if err != nil { + return nil, fmt.Errorf("config: open %s input: %w", kind, err) + } + if reader == nil { + return nil, fmt.Errorf("config: %s input returned a nil reader", kind) + } + defer func() { _ = reader.Close() }() + + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("config: read %s input: %w", kind, err) + } + + return data, nil +} diff --git a/config/input_test.go b/config/input_test.go new file mode 100644 index 0000000..e4f4b6b --- /dev/null +++ b/config/input_test.go @@ -0,0 +1,130 @@ +package config_test + +import ( + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/devctllabs/go-libs/config" + "github.com/devctllabs/go-libs/config/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestPathOpensFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("value: file"), 0o600)) + + reader, err := config.Path(path).Open(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reader.Close()) }) + + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "value: file", string(data)) +} + +func TestPathPreservesNotExist(t *testing.T) { + t.Parallel() + + reader, err := config.Path(filepath.Join(t.TempDir(), "missing.yaml")).Open(context.Background()) + + require.Nil(t, reader) + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestFromFSOpensFile(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.toml": {Data: []byte(`value = "fs"`)}, + } + + reader, err := config.FromFS(filesystem, "config.toml").Open(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reader.Close()) }) + + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, `value = "fs"`, string(data)) +} + +func TestInputHonorsCanceledContext(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + reader, err := config.FromFS(fstest.MapFS{}, "config.yaml").Open(ctx) + + require.Nil(t, reader) + require.ErrorIs(t, err, context.Canceled) +} + +type closeErrorReader struct { + io.Reader + closed bool +} + +func (r *closeErrorReader) Close() error { + r.closed = true + return errors.New("close failed") +} + +func TestFormatLoadersCloseInputAndIgnoreCloseError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + loader func(config.Input) config.Loader + target any + }{ + { + name: "YAML", + content: "value: decoded\n", + loader: config.YAML, + target: &map[string]string{}, + }, + { + name: "dotenv", + content: "TEST_APP_HOST=decoded\n", + loader: config.DotEnv, + target: &envConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + input := mocks.NewMockInput(ctrl) + reader := &closeErrorReader{Reader: strings.NewReader(tt.content)} + input.EXPECT().Open(gomock.Any()).Return(reader, nil) + + err := tt.loader(input).Load(context.Background(), tt.target) + + require.NoError(t, err) + require.True(t, reader.closed) + }) + } +} + +func TestFormatLoaderRejectsTypedNilInput(t *testing.T) { + t.Parallel() + + var input *mocks.MockInput + + err := config.YAML(input).Load(context.Background(), &struct{}{}) + + require.ErrorContains(t, err, "nil YAML input") +} diff --git a/config/loader.go b/config/loader.go new file mode 100644 index 0000000..e09d900 --- /dev/null +++ b/config/loader.go @@ -0,0 +1,110 @@ +package config + +import ( + "context" + "errors" + "fmt" + "io/fs" + "reflect" +) + +//go:generate go tool mockgen -destination mocks/config.gen.go -package mocks . Input,Loader + +// Loader applies one configuration source to a caller-owned target. +type Loader interface { + // Load applies configuration values to target. Implementations may partially + // update target before returning an error. Built-in loaders expect target to + // be a non-nil pointer supported by their underlying decoder. + Load(ctx context.Context, target any) error +} + +// TypedLoader loads one configuration source into a target of type T. +type TypedLoader[T any] interface { + // Load applies configuration values to target. Implementations may partially + // update target before returning an error. + Load(ctx context.Context, target *T) error +} + +type chainLoader struct { + loaders []Loader +} + +type optionalLoader struct { + loader Loader +} + +type typedLoader[T any] struct { + loader TypedLoader[T] +} + +// Chain combines loaders into one ordered, fail-fast Loader. Later loaders see +// and may override changes made by earlier loaders. Chain owns a copy of the +// supplied loader list and reports a nil loader when Load reaches it. +func Chain(loaders ...Loader) Loader { + return chainLoader{loaders: append([]Loader(nil), loaders...)} +} + +// Optional ignores only errors from loader that match fs.ErrNotExist. It +// preserves parse, validation, permission, cancellation, and other errors. +func Optional(loader Loader) Loader { + return optionalLoader{loader: loader} +} + +// Typed adapts a TypedLoader to Loader. Its Load method requires target to have +// type *T and returns an error for a different target type or nil loader. +func Typed[T any](loader TypedLoader[T]) Loader { + return typedLoader[T]{loader: loader} +} + +func (l chainLoader) Load(ctx context.Context, target any) error { + for i, loader := range l.loaders { + if isNilValue(loader) { + return fmt.Errorf("config: loader %d: nil loader", i+1) + } + if err := loader.Load(ctx, target); err != nil { + return fmt.Errorf("config: loader %d: %w", i+1, err) + } + } + + return nil +} + +func (l optionalLoader) Load(ctx context.Context, target any) error { + if isNilValue(l.loader) { + return errors.New("config: optional: nil loader") + } + + err := l.loader.Load(ctx, target) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + + return err +} + +func (l typedLoader[T]) Load(ctx context.Context, target any) error { + if isNilValue(l.loader) { + return errors.New("config: typed: nil loader") + } + + typedTarget, ok := target.(*T) + if !ok { + return fmt.Errorf("config: typed loader expects %T, got %T", new(T), target) + } + + return l.loader.Load(ctx, typedTarget) +} + +func isNilValue(value any) bool { + if value == nil { + return true + } + + reflected := reflect.ValueOf(value) + switch reflected.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return reflected.IsNil() + default: + return false + } +} diff --git a/config/loader_test.go b/config/loader_test.go new file mode 100644 index 0000000..9d4115a --- /dev/null +++ b/config/loader_test.go @@ -0,0 +1,180 @@ +package config_test + +import ( + "context" + "errors" + "fmt" + "io/fs" + "testing" + + "github.com/devctllabs/go-libs/config" + "github.com/devctllabs/go-libs/config/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestEmptyChainDoesNothing(t *testing.T) { + t.Parallel() + + target := struct{ Value string }{Value: "unchanged"} + + err := config.Chain().Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "unchanged", target.Value) +} + +func TestChainLoadsInOrder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + first := mocks.NewMockLoader(ctrl) + second := mocks.NewMockLoader(ctrl) + target := &struct{}{} + + gomock.InOrder( + first.EXPECT().Load(gomock.Any(), target).Return(nil), + second.EXPECT().Load(gomock.Any(), target).Return(nil), + ) + + err := config.Chain(first, second).Load(context.Background(), target) + + require.NoError(t, err) +} + +func TestChainStopsAtFirstError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + first := mocks.NewMockLoader(ctrl) + second := mocks.NewMockLoader(ctrl) + third := mocks.NewMockLoader(ctrl) + target := &struct{}{} + wantErr := errors.New("load failed") + + gomock.InOrder( + first.EXPECT().Load(gomock.Any(), target).Return(nil), + second.EXPECT().Load(gomock.Any(), target).Return(wantErr), + ) + + err := config.Chain(first, second, third).Load(context.Background(), target) + + require.ErrorIs(t, err, wantErr) + require.ErrorContains(t, err, "loader 2") +} + +func TestOptionalIgnoresNotExist(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + loader := mocks.NewMockLoader(ctrl) + target := &struct{}{} + loader.EXPECT().Load(gomock.Any(), target).Return(fmt.Errorf("open config: %w", fs.ErrNotExist)) + + err := config.Optional(loader).Load(context.Background(), target) + + require.NoError(t, err) +} + +func TestOptionalPreservesOtherErrors(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + loader := mocks.NewMockLoader(ctrl) + target := &struct{}{} + wantErr := fs.ErrPermission + loader.EXPECT().Load(gomock.Any(), target).Return(wantErr) + + err := config.Optional(loader).Load(context.Background(), target) + + require.ErrorIs(t, err, wantErr) +} + +type typedTarget struct { + Value string +} + +type typedTargetLoader struct{} + +func (typedTargetLoader) Load(_ context.Context, target *typedTarget) error { + target.Value = "loaded" + return nil +} + +func TestTypedAdaptsConcreteTarget(t *testing.T) { + t.Parallel() + + target := &typedTarget{} + + err := config.Typed(typedTargetLoader{}).Load(context.Background(), target) + + require.NoError(t, err) + require.Equal(t, "loaded", target.Value) +} + +func TestTypedRejectsDifferentTarget(t *testing.T) { + t.Parallel() + + err := config.Typed(typedTargetLoader{}).Load(context.Background(), &struct{}{}) + + require.ErrorContains(t, err, "expects *config_test.typedTarget") +} + +func TestChainRejectsNilLoader(t *testing.T) { + t.Parallel() + + err := config.Chain(nil).Load(context.Background(), &struct{}{}) + + require.ErrorContains(t, err, "nil loader") +} + +func TestOptionalRejectsNilLoader(t *testing.T) { + t.Parallel() + + err := config.Optional(nil).Load(context.Background(), &struct{}{}) + + require.ErrorContains(t, err, "nil loader") +} + +func TestChainOwnsLoaderSlice(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + first := mocks.NewMockLoader(ctrl) + second := mocks.NewMockLoader(ctrl) + target := &struct{}{} + loaders := []config.Loader{first} + loader := config.Chain(loaders...) + loaders[0] = second + first.EXPECT().Load(gomock.Any(), target).Return(nil) + + err := loader.Load(context.Background(), target) + + require.NoError(t, err) +} + +func TestChainRejectsTypedNilLoader(t *testing.T) { + t.Parallel() + + var loader *mocks.MockLoader + + err := config.Chain(loader).Load(context.Background(), &struct{}{}) + + require.ErrorContains(t, err, "nil loader") +} + +type nilTypedLoader struct{} + +func (*nilTypedLoader) Load(_ context.Context, _ *typedTarget) error { + panic("must not be called") +} + +func TestTypedRejectsTypedNilLoader(t *testing.T) { + t.Parallel() + + var loader *nilTypedLoader + + err := config.Typed(loader).Load(context.Background(), &typedTarget{}) + + require.ErrorContains(t, err, "nil loader") +} diff --git a/config/mocks/config.gen.go b/config/mocks/config.gen.go new file mode 100644 index 0000000..7cd4b95 --- /dev/null +++ b/config/mocks/config.gen.go @@ -0,0 +1,95 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/config (interfaces: Input,Loader) +// +// Generated by this command: +// +// mockgen -destination mocks/config.gen.go -package mocks . Input,Loader +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockInput is a mock of Input interface. +type MockInput struct { + ctrl *gomock.Controller + recorder *MockInputMockRecorder + isgomock struct{} +} + +// MockInputMockRecorder is the mock recorder for MockInput. +type MockInputMockRecorder struct { + mock *MockInput +} + +// NewMockInput creates a new mock instance. +func NewMockInput(ctrl *gomock.Controller) *MockInput { + mock := &MockInput{ctrl: ctrl} + mock.recorder = &MockInputMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockInput) EXPECT() *MockInputMockRecorder { + return m.recorder +} + +// Open mocks base method. +func (m *MockInput) Open(ctx context.Context) (io.ReadCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Open", ctx) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Open indicates an expected call of Open. +func (mr *MockInputMockRecorder) Open(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockInput)(nil).Open), ctx) +} + +// MockLoader is a mock of Loader interface. +type MockLoader struct { + ctrl *gomock.Controller + recorder *MockLoaderMockRecorder + isgomock struct{} +} + +// MockLoaderMockRecorder is the mock recorder for MockLoader. +type MockLoaderMockRecorder struct { + mock *MockLoader +} + +// NewMockLoader creates a new mock instance. +func NewMockLoader(ctrl *gomock.Controller) *MockLoader { + mock := &MockLoader{ctrl: ctrl} + mock.recorder = &MockLoaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLoader) EXPECT() *MockLoaderMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockLoader) Load(ctx context.Context, target any) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", ctx, target) + ret0, _ := ret[0].(error) + return ret0 +} + +// Load indicates an expected call of Load. +func (mr *MockLoaderMockRecorder) Load(ctx, target any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockLoader)(nil).Load), ctx, target) +} diff --git a/config/overlay.go b/config/overlay.go new file mode 100644 index 0000000..909eed8 --- /dev/null +++ b/config/overlay.go @@ -0,0 +1,148 @@ +package config + +import ( + "encoding" + "reflect" + "strings" +) + +type fieldNames struct { + tag string + defaultName func(string) string + fold bool +} + +func decodeStructOverlay( + target any, + values map[string]any, + names fieldNames, + decode func(target any) error, +) error { + targetValue := reflect.ValueOf(target) + decoded := reflect.New(targetValue.Elem().Type()) + if err := decode(decoded.Interface()); err != nil { + return err + } + + applyStructOverlay(targetValue.Elem(), decoded.Elem(), values, names) + return nil +} + +func applyStructOverlay(dst, src reflect.Value, values map[string]any, names fieldNames) { + dstType := dst.Type() + for i := 0; i < dstType.NumField(); i++ { + fieldType := dstType.Field(i) + if fieldType.PkgPath != "" { + continue + } + + name, ignored := sourceFieldName(fieldType, names) + if ignored { + continue + } + + raw, ok := sourceValue(values, name, names.fold) + if !ok { + continue + } + + dstField := dst.Field(i) + srcField := src.Field(i) + if raw == nil { + if isNullable(dstField.Kind()) { + dstField.Set(reflect.Zero(dstField.Type())) + } + continue + } + + nested, isMap := raw.(map[string]any) + if isMap && overlaysStruct(dstField.Type()) { + applyNestedStructOverlay(dstField, srcField, nested, names) + continue + } + + dstField.Set(srcField) + } +} + +func applyNestedStructOverlay(dst, src reflect.Value, values map[string]any, names fieldNames) { + if dst.Kind() == reflect.Pointer { + if src.IsNil() { + return + } + if dst.IsNil() { + dst.Set(reflect.New(dst.Type().Elem())) + } + dst = dst.Elem() + src = src.Elem() + } + + applyStructOverlay(dst, src, values, names) +} + +func sourceFieldName(field reflect.StructField, names fieldNames) (string, bool) { + name := names.defaultName(field.Name) + if tag, ok := field.Tag.Lookup(names.tag); ok { + if tag == "-" { + return "", true + } + if taggedName, _, _ := strings.Cut(tag, ","); taggedName != "" { + name = taggedName + } + } + + return name, false +} + +func sourceValue(values map[string]any, name string, fold bool) (any, bool) { + value, ok := values[name] + if ok || !fold { + return value, ok + } + + for key, value := range values { + if strings.EqualFold(key, name) { + return value, true + } + } + + return nil, false +} + +func overlaysStruct(fieldType reflect.Type) bool { + if fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + if fieldType.Kind() != reflect.Struct { + return false + } + textUnmarshalerType := reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + if reflect.PointerTo(fieldType).Implements(textUnmarshalerType) { + return false + } + + for i := 0; i < fieldType.NumField(); i++ { + if fieldType.Field(i).PkgPath == "" { + return true + } + } + + return false +} + +func isStructPointer(target any) bool { + targetValue := reflect.ValueOf(target) + return targetValue.IsValid() && + targetValue.Kind() == reflect.Pointer && + !targetValue.IsNil() && + targetValue.Elem().Kind() == reflect.Struct +} + +func isNullable(kind reflect.Kind) bool { + switch kind { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return true + default: + return false + } +} diff --git a/config/toml.go b/config/toml.go new file mode 100644 index 0000000..7f931e6 --- /dev/null +++ b/config/toml.go @@ -0,0 +1,51 @@ +package config + +import ( + "context" + "fmt" + + "github.com/pelletier/go-toml/v2" +) + +type tomlLoader struct { + input Input +} + +// TOML creates a loader for TOML data from input. For struct targets, "toml" +// tags select fields, present nested struct fields are overlaid, and maps and +// slices are replaced as whole values. +func TOML(input Input) Loader { + return tomlLoader{input: input} +} + +func (l tomlLoader) Load(ctx context.Context, target any) error { + data, err := readInput(ctx, l.input, "TOML") + if err != nil { + return err + } + + if !isStructPointer(target) { + if err := toml.Unmarshal(data, target); err != nil { + return fmt.Errorf("config: decode TOML: %w", err) + } + return nil + } + + var values map[string]any + if err := toml.Unmarshal(data, &values); err != nil { + return fmt.Errorf("config: parse TOML: %w", err) + } + + err = decodeStructOverlay(target, values, fieldNames{ + tag: "toml", + defaultName: func(name string) string { return name }, + fold: true, + }, func(target any) error { + return toml.Unmarshal(data, target) + }) + if err != nil { + return fmt.Errorf("config: decode TOML: %w", err) + } + + return nil +} diff --git a/config/toml_test.go b/config/toml_test.go new file mode 100644 index 0000000..59fa36b --- /dev/null +++ b/config/toml_test.go @@ -0,0 +1,98 @@ +package config_test + +import ( + "context" + "testing" + "testing/fstest" + + "github.com/devctllabs/go-libs/config" + "github.com/stretchr/testify/require" +) + +func TestTOMLOverlaysStructAndReplacesCollections(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.toml": {Data: []byte(` +[server] +port = 2000 +names = ["new"] +unknown = "ignored" + +[server.headers] +new = "value" +`)}, + } + target := fileConfig{ + Server: &fileServerConfig{ + Host: "existing", + Port: 1000, + Headers: map[string]string{"old": "value"}, + Names: []string{"old"}, + }, + Ignored: "existing", + } + + err := config.TOML(config.FromFS(filesystem, "config.toml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "existing", target.Server.Host) + require.Equal(t, 2000, target.Server.Port) + require.Equal(t, map[string]string{"new": "value"}, target.Server.Headers) + require.Equal(t, []string{"new"}, target.Server.Names) + require.Equal(t, "existing", target.Ignored) +} + +func TestTOMLAllocatesNestedPointer(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.toml": {Data: []byte("[server]\nhost = \"created\"\n")}, + } + var target fileConfig + + err := config.TOML(config.FromFS(filesystem, "config.toml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.NotNil(t, target.Server) + require.Equal(t, "created", target.Server.Host) +} + +func TestEmptyTOMLDoesNothing(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{"config.toml": {Data: nil}} + target := fileConfig{Server: &fileServerConfig{Host: "existing"}} + + err := config.TOML(config.FromFS(filesystem, "config.toml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "existing", target.Server.Host) +} + +func TestTOMLReturnsParseError(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.toml": {Data: []byte("invalid = [\n")}, + } + var target fileConfig + + err := config.TOML(config.FromFS(filesystem, "config.toml")).Load(context.Background(), &target) + + require.Error(t, err) +} + +func TestTOMLDelegatesNonStructTargetToNativeDecoder(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.toml": {Data: []byte(`value = "decoded"`)}, + } + target := map[string]string{} + + err := config.TOML(config.FromFS(filesystem, "config.toml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, map[string]string{"value": "decoded"}, target) +} diff --git a/config/yaml.go b/config/yaml.go new file mode 100644 index 0000000..abc70fa --- /dev/null +++ b/config/yaml.go @@ -0,0 +1,67 @@ +package config + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + + "go.yaml.in/yaml/v3" +) + +type yamlLoader struct { + input Input +} + +// YAML creates a loader for one YAML document from input. For struct targets, +// "yaml" tags select fields, present nested struct fields are overlaid, and maps +// and slices are replaced as whole values. Multiple documents are rejected. +func YAML(input Input) Loader { + return yamlLoader{input: input} +} + +func (l yamlLoader) Load(ctx context.Context, target any) error { + data, err := readInput(ctx, l.input, "YAML") + if err != nil { + return err + } + + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var document yaml.Node + if err := decoder.Decode(&document); errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return fmt.Errorf("config: parse YAML: %w", err) + } + + var extra yaml.Node + if err := decoder.Decode(&extra); err == nil { + return errors.New("config: YAML input contains multiple documents") + } else if !errors.Is(err, io.EOF) { + return fmt.Errorf("config: parse YAML: %w", err) + } + + if !isStructPointer(target) { + if err := document.Decode(target); err != nil { + return fmt.Errorf("config: decode YAML: %w", err) + } + return nil + } + + var values map[string]any + if err := document.Decode(&values); err != nil { + return fmt.Errorf("config: decode YAML fields: %w", err) + } + + err = decodeStructOverlay(target, values, fieldNames{ + tag: "yaml", + defaultName: strings.ToLower, + }, document.Decode) + if err != nil { + return fmt.Errorf("config: decode YAML: %w", err) + } + + return nil +} diff --git a/config/yaml_test.go b/config/yaml_test.go new file mode 100644 index 0000000..5d288a7 --- /dev/null +++ b/config/yaml_test.go @@ -0,0 +1,108 @@ +package config_test + +import ( + "context" + "testing" + "testing/fstest" + + "github.com/devctllabs/go-libs/config" + "github.com/stretchr/testify/require" +) + +type fileServerConfig struct { + Host string `yaml:"host" toml:"host"` + Port int `yaml:"port" toml:"port"` + Headers map[string]string `yaml:"headers" toml:"headers"` + Names []string `yaml:"names" toml:"names"` +} + +type fileConfig struct { + Server *fileServerConfig `yaml:"server" toml:"server"` + Ignored string `yaml:"-" toml:"-"` +} + +func TestYAMLOverlaysStructAndReplacesCollections(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.yaml": {Data: []byte(` +server: + port: 2000 + headers: + new: value + names: [new] +unknown: ignored +`)}, + } + target := fileConfig{ + Server: &fileServerConfig{ + Host: "existing", + Port: 1000, + Headers: map[string]string{"old": "value"}, + Names: []string{"old"}, + }, + Ignored: "existing", + } + + err := config.YAML(config.FromFS(filesystem, "config.yaml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "existing", target.Server.Host) + require.Equal(t, 2000, target.Server.Port) + require.Equal(t, map[string]string{"new": "value"}, target.Server.Headers) + require.Equal(t, []string{"new"}, target.Server.Names) + require.Equal(t, "existing", target.Ignored) +} + +func TestYAMLNullClearsPointer(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.yaml": {Data: []byte("server: null\n")}, + } + target := fileConfig{Server: &fileServerConfig{Host: "existing"}} + + err := config.YAML(config.FromFS(filesystem, "config.yaml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Nil(t, target.Server) +} + +func TestEmptyYAMLDoesNothing(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{"config.yaml": {Data: nil}} + target := fileConfig{Server: &fileServerConfig{Host: "existing"}} + + err := config.YAML(config.FromFS(filesystem, "config.yaml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, "existing", target.Server.Host) +} + +func TestYAMLRejectsMultipleDocuments(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.yaml": {Data: []byte("server:\n port: 1000\n---\nserver:\n port: 2000\n")}, + } + var target fileConfig + + err := config.YAML(config.FromFS(filesystem, "config.yaml")).Load(context.Background(), &target) + + require.ErrorContains(t, err, "multiple documents") +} + +func TestYAMLDelegatesNonStructTargetToNativeDecoder(t *testing.T) { + t.Parallel() + + filesystem := fstest.MapFS{ + "config.yaml": {Data: []byte("value: decoded\n")}, + } + target := map[string]string{} + + err := config.YAML(config.FromFS(filesystem, "config.yaml")).Load(context.Background(), &target) + + require.NoError(t, err) + require.Equal(t, map[string]string{"value": "decoded"}, target) +} diff --git a/debugserver/doc.go b/debugserver/doc.go new file mode 100644 index 0000000..c6a9468 --- /dev/null +++ b/debugserver/doc.go @@ -0,0 +1,15 @@ +// Package debugserver provides a standalone HTTP server for Go pprof endpoints. +// +// The server listens on 127.0.0.1:6060 by default and serves diagnostics from +// a private http.ServeMux. Keep it bound to a loopback or otherwise private +// address and access it through an authenticated operational channel such as +// kubectl port-forward. Do not expose it through a public Service or Ingress. +// +// Importing net/http/pprof registers the same handlers on http.DefaultServeMux +// as a package initialization side effect. Package debugserver does not serve +// that mux, but applications must not expose http.DefaultServeMux publicly. +// +// Mutex and block profiling rates are process-global runtime settings. This +// package does not change them; applications that need those profiles must set +// and own that policy explicitly. +package debugserver diff --git a/debugserver/example_test.go b/debugserver/example_test.go new file mode 100644 index 0000000..8a28b74 --- /dev/null +++ b/debugserver/example_test.go @@ -0,0 +1,16 @@ +package debugserver_test + +import ( + "log" + "os" + + "github.com/devctllabs/go-libs/debugserver" +) + +func ExampleNewServer() { + logger := log.New(os.Stdout, "", 0) + server, _ := debugserver.NewServer() + logger.Println(server.Address()) + + // Output: 127.0.0.1:6060 +} diff --git a/debugserver/go.mod b/debugserver/go.mod new file mode 100644 index 0000000..8ee9bdb --- /dev/null +++ b/debugserver/go.mod @@ -0,0 +1,11 @@ +module github.com/devctllabs/go-libs/debugserver + +go 1.25.0 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/debugserver/go.sum b/debugserver/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/debugserver/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/debugserver/server.go b/debugserver/server.go new file mode 100644 index 0000000..3013245 --- /dev/null +++ b/debugserver/server.go @@ -0,0 +1,102 @@ +package debugserver + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/pprof" + "strings" + "time" +) + +type serverConfig struct { + address string +} + +// Option configures a Server during construction. +type Option interface { + apply(*serverConfig) error +} + +type serverOptionFunc func(*serverConfig) error + +func (f serverOptionFunc) apply(cfg *serverConfig) error { + return f(cfg) +} + +// WithAddress replaces the default loopback address 127.0.0.1:6060. +func WithAddress(address string) Option { + return serverOptionFunc(func(cfg *serverConfig) error { + if strings.TrimSpace(address) == "" { + return errors.New("debugserver: address must not be blank") + } + cfg.address = address + return nil + }) +} + +// Server owns the standalone pprof HTTP server lifecycle. +type Server struct { + http *http.Server +} + +// NewServer creates a standalone pprof server without starting it. +func NewServer(options ...Option) (*Server, error) { + cfg := serverConfig{address: "127.0.0.1:6060"} + for _, option := range options { + if option == nil { + continue + } + if err := option.apply(&cfg); err != nil { + return nil, err + } + } + + return &Server{http: &http.Server{ + Addr: cfg.address, + Handler: pprofMux(), + ReadHeaderTimeout: 2 * time.Second, + IdleTimeout: 30 * time.Second, + }}, nil +} + +// Address returns the configured listen address. +func (s *Server) Address() string { + return s.http.Addr +} + +// ListenAndServe starts the configured TCP listener and blocks until shutdown or failure. +func (s *Server) ListenAndServe() error { + return normalizeServerError(s.http.ListenAndServe()) +} + +// Serve accepts HTTP connections from listener and blocks until shutdown or failure. +func (s *Server) Serve(listener net.Listener) error { + if listener == nil { + return errors.New("debugserver: listener must not be nil") + } + return normalizeServerError(s.http.Serve(listener)) +} + +// Shutdown gracefully stops the server using ctx as its deadline. +func (s *Server) Shutdown(ctx context.Context) error { + return s.http.Shutdown(ctx) +} + +func pprofMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /debug/pprof/", pprof.Index) + mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile) + mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace) + return mux +} + +func normalizeServerError(err error) error { + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} diff --git a/debugserver/server_test.go b/debugserver/server_test.go new file mode 100644 index 0000000..2f6b7db --- /dev/null +++ b/debugserver/server_test.go @@ -0,0 +1,118 @@ +package debugserver_test + +import ( + "context" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/devctllabs/go-libs/debugserver" + "github.com/stretchr/testify/require" +) + +func TestNewServer(t *testing.T) { + t.Parallel() + + t.Run("uses the loopback default address", func(t *testing.T) { + t.Parallel() + + server, err := debugserver.NewServer() + + require.NoError(t, err) + require.Equal(t, "127.0.0.1:6060", server.Address()) + }) + + t.Run("accepts a custom address", func(t *testing.T) { + t.Parallel() + + server, err := debugserver.NewServer(debugserver.WithAddress("127.0.0.1:0")) + + require.NoError(t, err) + require.Equal(t, "127.0.0.1:0", server.Address()) + }) + + t.Run("ignores a nil option", func(t *testing.T) { + t.Parallel() + + server, err := debugserver.NewServer(nil) + + require.NoError(t, err) + require.Equal(t, "127.0.0.1:6060", server.Address()) + }) + + t.Run("rejects a blank address", func(t *testing.T) { + t.Parallel() + + server, err := debugserver.NewServer(debugserver.WithAddress(" \t ")) + + require.EqualError(t, err, "debugserver: address must not be blank") + require.Nil(t, server) + }) +} + +func TestServerServeRejectsNilListener(t *testing.T) { + t.Parallel() + + server, err := debugserver.NewServer() + require.NoError(t, err) + + require.EqualError(t, server.Serve(nil), "debugserver: listener must not be nil") +} + +func TestServerServesStandardPprofEndpoints(t *testing.T) { + t.Parallel() + server, err := debugserver.NewServer() + require.NoError(t, err) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + serveErr := make(chan error, 1) + go func() { + serveErr <- server.Serve(listener) + }() + t.Cleanup(func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, server.Shutdown(shutdownCtx)) + require.NoError(t, <-serveErr) + }) + + client := &http.Client{Timeout: 5 * time.Second} + baseURL := "http://" + listener.Addr().String() + + for _, path := range []string{ + "/debug/pprof/", + "/debug/pprof/goroutine?debug=1", + "/debug/pprof/cmdline", + "/debug/pprof/symbol", + "/debug/pprof/profile?seconds=1", + "/debug/pprof/trace?seconds=0.01", + } { + response, requestErr := client.Get(baseURL + path) + require.NoError(t, requestErr, path) + _, requestErr = io.Copy(io.Discard, response.Body) + require.NoError(t, requestErr, path) + require.NoError(t, response.Body.Close(), path) + require.Equal(t, http.StatusOK, response.StatusCode, path) + } + + response, err := client.Get(baseURL + "/debug/pprof/unknown") + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Equal(t, http.StatusNotFound, response.StatusCode) + + response, err = client.Get(baseURL + "/") + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Equal(t, http.StatusNotFound, response.StatusCode) + + request, err := http.NewRequest(http.MethodPost, baseURL+"/debug/pprof/", nil) + require.NoError(t, err) + response, err = client.Do(request) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode) +} diff --git a/di/container.go b/di/container.go new file mode 100644 index 0000000..1a2c6f4 --- /dev/null +++ b/di/container.go @@ -0,0 +1,509 @@ +package di + +import ( + "context" + "errors" + "fmt" + "reflect" + "sort" + "sync" + "sync/atomic" + + do "github.com/samber/do/v2" +) + +// Provider lazily constructs one dependency. It must resolve dependencies +// synchronously through the provided Resolver so their graph edges can be +// tracked, and it must not retain Resolver after returning. Returning a nil +// dependency without an error causes resolution to fail with ErrNilService. +type Provider[T any] func(Resolver) (T, error) + +// Cleanup releases a resource owned by a Container. It is called at most once, +// only after the resource was successfully constructed, with the context from +// the first Shutdown call. A cleanup panic is recovered and returned as a +// shutdown error. +type Cleanup[T any] func(context.Context, T) error + +// Resolver is the restricted dependency lookup context passed to providers. +// It is valid only while that provider is running. Container also implements +// Resolver for top-level resolution. +type Resolver interface { + resolutionContext() resolveContext +} + +type phase uint8 + +const ( + phaseRegistering phase = iota + phaseSealed + phaseClosing + phaseClosed +) + +type registrationKind uint8 + +const ( + registrationSingleton registrationKind = iota + registrationResource +) + +type registration struct { + description string + kind registrationKind +} + +// Container stores dependency providers and owns explicitly registered +// resources. Create one with New; a zero Container is not usable. Container +// coordinates concurrent resolution and shutdown. +type Container struct { + engine *do.RootScope + + mu sync.Mutex + idle *sync.Cond + phase phase + activeResolve int + registrations map[string]registration + shutdownDone chan struct{} + shutdownErr error +} + +// New creates an empty dependency container in its registration phase. +func New() *Container { + container := &Container{ + engine: do.New(), + phase: phaseRegistering, + registrations: make(map[string]registration), + shutdownDone: make(chan struct{}), + } + container.idle = sync.NewCond(&container.mu) + return container +} + +type resolveContext struct { + container *Container + injector do.Injector + valid *atomic.Bool +} + +func (c *Container) resolutionContext() resolveContext { + if !validContainer(c) { + return resolveContext{} + } + return resolveContext{container: c, injector: c.engine} +} + +type providerResolver struct { + context resolveContext +} + +func (r *providerResolver) resolutionContext() resolveContext { + if r == nil { + return resolveContext{} + } + return r.context +} + +type singleton[T any] struct { + value T +} + +type resource[T any] struct { + value T + cleanup Cleanup[T] +} + +func (r *resource[T]) Shutdown(ctx context.Context) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("cleanup panic: %v", recovered) + } + }() + return r.cleanup(ctx, r.value) +} + +// Provide registers a lazy singleton by its type. Registration errors match +// ErrInvalidArgument, ErrDuplicate, ErrSealed, or ErrClosed. Provider errors +// are returned when the dependency is resolved. +func Provide[T any](container *Container, provider Provider[T]) error { + return provide(container, serviceKey[T](""), serviceDescription[T](""), provider, nil) +} + +// ProvideNamed registers a named lazy singleton. Names are scoped by type and +// must not be empty. Registration errors match ErrInvalidArgument, +// ErrDuplicate, ErrSealed, or ErrClosed. +func ProvideNamed[T any](container *Container, name string, provider Provider[T]) error { + if name == "" { + return fmt.Errorf("%w: dependency name is empty", ErrInvalidArgument) + } + return provide(container, serviceKey[T](name), serviceDescription[T](name), provider, nil) +} + +// ProvideResource registers a lazy singleton and transfers its cleanup +// ownership to the container. Provider and cleanup must be non-nil. +// Registration errors match ErrInvalidArgument, ErrDuplicate, ErrSealed, or +// ErrClosed. +func ProvideResource[T any](container *Container, provider Provider[T], cleanup Cleanup[T]) error { + if cleanup == nil { + return fmt.Errorf("%w: cleanup is nil", ErrInvalidArgument) + } + return provide(container, serviceKey[T](""), serviceDescription[T](""), provider, cleanup) +} + +// ProvideNamedResource registers a named lazy singleton and transfers its +// cleanup ownership to the container. Names are scoped by type and must not be +// empty; provider and cleanup must be non-nil. Registration errors match +// ErrInvalidArgument, ErrDuplicate, ErrSealed, or ErrClosed. +func ProvideNamedResource[T any]( + container *Container, + name string, + provider Provider[T], + cleanup Cleanup[T], +) error { + if name == "" { + return fmt.Errorf("%w: dependency name is empty", ErrInvalidArgument) + } + if cleanup == nil { + return fmt.Errorf("%w: cleanup is nil", ErrInvalidArgument) + } + return provide(container, serviceKey[T](name), serviceDescription[T](name), provider, cleanup) +} + +func provide[T any]( + container *Container, + key string, + description string, + provider Provider[T], + cleanup Cleanup[T], +) error { + if !validContainer(container) { + return fmt.Errorf("%w: container was not created with di.New", ErrInvalidArgument) + } + if provider == nil { + return fmt.Errorf("%w: provider for %s is nil", ErrInvalidArgument, description) + } + + kind := registrationSingleton + if cleanup != nil { + kind = registrationResource + } + + if cleanup == nil { + return container.register(key, registration{description: description, kind: kind}, func() { + do.ProvideNamed(container.engine, key, func(injector do.Injector) (*singleton[T], error) { + value, err := invokeProvider(container, injector, provider) + if err != nil { + return nil, err + } + return &singleton[T]{value: value}, nil + }) + }) + } + + return container.register(key, registration{description: description, kind: kind}, func() { + do.ProvideNamed(container.engine, key, func(injector do.Injector) (*resource[T], error) { + value, err := invokeProvider(container, injector, provider) + if err != nil { + return nil, err + } + return &resource[T]{value: value, cleanup: cleanup}, nil + }) + }) +} + +// ProvideValue registers an immediately available value by its type. The +// container does not infer cleanup ownership from the value's methods. A nil +// value fails with ErrNilService; other registration errors match +// ErrInvalidArgument, ErrDuplicate, ErrSealed, or ErrClosed. +func ProvideValue[T any](container *Container, value T) error { + return provideValue(container, serviceKey[T](""), serviceDescription[T](""), value) +} + +// ProvideNamedValue registers an immediately available named value. Names are +// scoped by type and must not be empty. The container does not infer cleanup +// ownership. A nil value fails with ErrNilService; other registration errors +// match ErrInvalidArgument, ErrDuplicate, ErrSealed, or ErrClosed. +func ProvideNamedValue[T any](container *Container, name string, value T) error { + if name == "" { + return fmt.Errorf("%w: dependency name is empty", ErrInvalidArgument) + } + return provideValue(container, serviceKey[T](name), serviceDescription[T](name), value) +} + +func provideValue[T any](container *Container, key, description string, value T) error { + if !validContainer(container) { + return fmt.Errorf("%w: container was not created with di.New", ErrInvalidArgument) + } + if isNil(value) { + return fmt.Errorf("%w: %s", ErrNilService, description) + } + return container.register(key, registration{ + description: description, + kind: registrationSingleton, + }, func() { + do.ProvideNamedValue(container.engine, key, &singleton[T]{value: value}) + }) +} + +func (c *Container) register(key string, candidate registration, register func()) error { + c.mu.Lock() + defer c.mu.Unlock() + + switch c.phase { + case phaseRegistering: + case phaseSealed: + return fmt.Errorf("%w: register %s", ErrSealed, candidate.description) + case phaseClosing, phaseClosed: + return fmt.Errorf("%w: register %s", ErrClosed, candidate.description) + } + if _, exists := c.registrations[key]; exists { + return fmt.Errorf("%w: %s", ErrDuplicate, candidate.description) + } + register() + c.registrations[key] = candidate + return nil +} + +func invokeProvider[T any](container *Container, injector do.Injector, provider Provider[T]) (T, error) { + valid := &atomic.Bool{} + valid.Store(true) + defer valid.Store(false) + resolver := &providerResolver{context: resolveContext{ + container: container, + injector: injector, + valid: valid, + }} + value, err := provider(resolver) + if err != nil { + var zero T + return zero, err + } + if isNil(value) { + var zero T + return zero, ErrNilService + } + return value, nil +} + +// Resolve returns the singleton registered for T, constructing it lazily when +// needed. Concurrent successful resolutions return the same singleton. The +// first top-level resolution seals the container against further registration. +// Resolution errors may match ErrInvalidArgument, ErrClosed, ErrNotFound, +// ErrCircularDependency, or ErrNilService; provider errors remain wrapped. +func Resolve[T any](resolver Resolver) (T, error) { + return resolve[T](resolver, "") +} + +// ResolveNamed returns the named singleton registered for T. Names are scoped +// by type and name must not be empty. The first top-level resolution seals the +// container. Its errors have the same categories as Resolve. +func ResolveNamed[T any](resolver Resolver, name string) (T, error) { + if name == "" { + var zero T + return zero, fmt.Errorf("%w: dependency name is empty", ErrInvalidArgument) + } + return resolve[T](resolver, name) +} + +func resolve[T any](resolver Resolver, name string) (T, error) { + var zero T + context, finish, err := beginResolution(resolver) + if err != nil { + return zero, err + } + defer finish() + + key := serviceKey[T](name) + description := serviceDescription[T](name) + registration, ok := context.container.lookup(key) + if !ok { + return zero, fmt.Errorf("resolve %s: %w", description, ErrNotFound) + } + + switch registration.kind { + case registrationSingleton: + wrapped, invokeErr := do.InvokeNamed[*singleton[T]](context.injector, key) + if invokeErr != nil { + return zero, resolveError(description, invokeErr) + } + return wrapped.value, nil + case registrationResource: + wrapped, invokeErr := do.InvokeNamed[*resource[T]](context.injector, key) + if invokeErr != nil { + return zero, resolveError(description, invokeErr) + } + return wrapped.value, nil + default: + return zero, fmt.Errorf("resolve %s: %w", description, ErrNotFound) + } +} + +func beginResolution(resolver Resolver) (resolveContext, func(), error) { + if resolver == nil { + return resolveContext{}, func() {}, fmt.Errorf("%w: resolver is nil", ErrInvalidArgument) + } + context := resolver.resolutionContext() + if context.container == nil || isNil(context.injector) { + return resolveContext{}, func() {}, fmt.Errorf("%w: resolver is invalid", ErrInvalidArgument) + } + if context.valid != nil { + if !context.valid.Load() { + return resolveContext{}, func() {}, fmt.Errorf("%w: provider resolver expired", ErrInvalidArgument) + } + return context, func() {}, nil + } + + container := context.container + container.mu.Lock() + switch container.phase { + case phaseRegistering: + container.phase = phaseSealed + case phaseSealed: + case phaseClosing, phaseClosed: + container.mu.Unlock() + return resolveContext{}, func() {}, ErrClosed + } + container.activeResolve++ + container.mu.Unlock() + + return context, func() { + container.mu.Lock() + container.activeResolve-- + container.idle.Broadcast() + container.mu.Unlock() + }, nil +} + +func (c *Container) lookup(key string) (registration, bool) { + c.mu.Lock() + defer c.mu.Unlock() + registration, ok := c.registrations[key] + return registration, ok +} + +func resolveError(description string, err error) error { + if errors.Is(err, do.ErrCircularDependency) { + return fmt.Errorf("resolve %s: %w: %s", description, ErrCircularDependency, err) + } + if errors.Is(err, do.ErrServiceNotFound) { + return fmt.Errorf("resolve %s: %w", description, ErrNotFound) + } + return fmt.Errorf("resolve %s: %w", description, err) +} + +// Shutdown stops all constructed owned resources. Dependents are stopped +// before dependencies, and independent branches may stop concurrently. +// +// Shutdown waits for resolutions that started before shutdown, rejects new +// operations, and caches the result. The first caller's context controls the +// shutdown; concurrent and later callers receive the cached result. Cleanup +// failures are joined, remain compatible with errors.Is, and do not prevent +// other independent cleanups from running. Cleanup panics are converted to +// errors. An invalid Container or nil context causes ErrInvalidArgument. +func (c *Container) Shutdown(ctx context.Context) error { + if !validContainer(c) { + return fmt.Errorf("%w: container was not created with di.New", ErrInvalidArgument) + } + if ctx == nil || isNil(ctx) { + return fmt.Errorf("%w: context is nil", ErrInvalidArgument) + } + + c.mu.Lock() + switch c.phase { + case phaseClosed: + err := c.shutdownErr + c.mu.Unlock() + return err + case phaseClosing: + done := c.shutdownDone + c.mu.Unlock() + <-done + c.mu.Lock() + err := c.shutdownErr + c.mu.Unlock() + return err + case phaseRegistering, phaseSealed: + c.phase = phaseClosing + } + for c.activeResolve > 0 { + c.idle.Wait() + } + c.mu.Unlock() + + report := c.engine.ShutdownWithContext(ctx) + shutdownErr := c.joinShutdownErrors(report) + + c.mu.Lock() + c.shutdownErr = shutdownErr + c.phase = phaseClosed + close(c.shutdownDone) + c.mu.Unlock() + return shutdownErr +} + +func (c *Container) joinShutdownErrors(report *do.ShutdownReport) error { + if report == nil || len(report.Errors) == 0 { + return nil + } + + type failure struct { + description string + err error + } + failures := make([]failure, 0, len(report.Errors)) + for service, err := range report.Errors { + if err == nil { + continue + } + description := service.Service + if registered, ok := c.lookup(service.Service); ok { + description = registered.description + } + failures = append(failures, failure{description: description, err: err}) + } + sort.Slice(failures, func(i, j int) bool { + return failures[i].description < failures[j].description + }) + + errorsToJoin := make([]error, 0, len(failures)) + for _, failure := range failures { + errorsToJoin = append(errorsToJoin, fmt.Errorf("shutdown %s: %w", failure.description, failure.err)) + } + return errors.Join(errorsToJoin...) +} + +func serviceKey[T any](name string) string { + key := "type:" + do.NameOf[T]() + if name == "" { + return key + } + return key + ":name:" + name +} + +func serviceDescription[T any](name string) string { + description := reflect.TypeFor[T]().String() + if name == "" { + return description + } + return description + "[" + name + "]" +} + +func isNil[T any](value T) bool { + reflected := reflect.ValueOf(value) + if !reflected.IsValid() { + return true + } + switch reflected.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return reflected.IsNil() + default: + return false + } +} + +func validContainer(container *Container) bool { + return container != nil && + container.engine != nil && + container.idle != nil && + container.registrations != nil && + container.shutdownDone != nil +} diff --git a/di/di_test.go b/di/di_test.go new file mode 100644 index 0000000..11ccd7f --- /dev/null +++ b/di/di_test.go @@ -0,0 +1,426 @@ +package di_test + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/devctllabs/go-libs/di" + "github.com/stretchr/testify/require" +) + +type testDependency struct{} +type testDependent struct{} +type testUnused struct{} +type testOther struct{} + +func TestResolveBuildsSingletonOnceConcurrently(t *testing.T) { + t.Parallel() + + container := di.New() + var builds atomic.Int32 + require.NoError(t, di.Provide(container, func(di.Resolver) (*testDependency, error) { + builds.Add(1) + return &testDependency{}, nil + })) + + const goroutines = 32 + values := make(chan *testDependency, goroutines) + errors := make(chan error, goroutines) + var group sync.WaitGroup + group.Add(goroutines) + for range goroutines { + go func() { + defer group.Done() + value, err := di.Resolve[*testDependency](container) + values <- value + errors <- err + }() + } + group.Wait() + close(values) + close(errors) + + for err := range errors { + require.NoError(t, err) + } + var first *testDependency + for value := range values { + if first == nil { + first = value + } + require.Same(t, first, value) + } + require.Equal(t, int32(1), builds.Load()) +} + +func TestNamedServicesAreIsolatedByType(t *testing.T) { + t.Parallel() + + container := di.New() + require.NoError(t, di.ProvideNamedValue(container, "primary", 42)) + require.NoError(t, di.ProvideNamedValue(container, "primary", "value")) + + integer, err := di.ResolveNamed[int](container, "primary") + require.NoError(t, err) + require.Equal(t, 42, integer) + + text, err := di.ResolveNamed[string](container, "primary") + require.NoError(t, err) + require.Equal(t, "value", text) +} + +func TestRegistrationValidationAndAutoSeal(t *testing.T) { + t.Parallel() + + container := di.New() + require.ErrorIs(t, di.ProvideNamedValue(container, "", 1), di.ErrInvalidArgument) + + var nilProvider di.Provider[int] + require.ErrorIs(t, di.Provide(container, nilProvider), di.ErrInvalidArgument) + + require.NoError(t, di.ProvideValue(container, 1)) + require.ErrorIs(t, di.ProvideValue(container, 2), di.ErrDuplicate) + + value, err := di.Resolve[int](container) + require.NoError(t, err) + require.Equal(t, 1, value) + require.ErrorIs(t, di.ProvideValue(container, "late"), di.ErrSealed) +} + +func TestRegistrationIsAtomicWithFirstResolve(t *testing.T) { + t.Parallel() + + for range 500 { + container := di.New() + start := make(chan struct{}) + provideResult := make(chan error, 1) + resolveResult := make(chan error, 1) + + go func() { + <-start + provideResult <- di.ProvideValue(container, 42) + }() + go func() { + <-start + _, err := di.Resolve[int](container) + resolveResult <- err + }() + close(start) + + provideErr := <-provideResult + resolveErr := <-resolveResult + if provideErr == nil { + require.NoError(t, resolveErr) + continue + } + require.ErrorIs(t, provideErr, di.ErrSealed) + require.ErrorIs(t, resolveErr, di.ErrNotFound) + } +} + +func TestZeroContainerIsRejected(t *testing.T) { + t.Parallel() + + container := &di.Container{} + require.ErrorIs(t, di.ProvideValue(container, 1), di.ErrInvalidArgument) + _, err := di.Resolve[int](container) + require.ErrorIs(t, err, di.ErrInvalidArgument) + require.ErrorIs(t, container.Shutdown(context.Background()), di.ErrInvalidArgument) +} + +func TestResolveReportsMissingCycleProviderAndNilErrors(t *testing.T) { + t.Parallel() + + t.Run("missing", func(t *testing.T) { + t.Parallel() + container := di.New() + _, err := di.Resolve[*testDependency](container) + require.ErrorIs(t, err, di.ErrNotFound) + require.ErrorContains(t, err, "*di_test.testDependency") + }) + + t.Run("cycle", func(t *testing.T) { + t.Parallel() + container := di.New() + require.NoError(t, di.Provide(container, func(resolver di.Resolver) (*testDependency, error) { + _, err := di.Resolve[*testDependent](resolver) + return &testDependency{}, err + })) + require.NoError(t, di.Provide(container, func(resolver di.Resolver) (*testDependent, error) { + _, err := di.Resolve[*testDependency](resolver) + return &testDependent{}, err + })) + + _, err := di.Resolve[*testDependency](container) + require.ErrorIs(t, err, di.ErrCircularDependency) + }) + + t.Run("provider", func(t *testing.T) { + t.Parallel() + sentinel := errors.New("provider failed") + container := di.New() + require.NoError(t, di.Provide(container, func(di.Resolver) (int, error) { + return 0, sentinel + })) + + _, err := di.Resolve[int](container) + require.ErrorIs(t, err, sentinel) + }) + + t.Run("nil service", func(t *testing.T) { + t.Parallel() + container := di.New() + require.NoError(t, di.Provide(container, func(di.Resolver) (*testDependency, error) { + return nil, nil + })) + + _, err := di.Resolve[*testDependency](container) + require.ErrorIs(t, err, di.ErrNilService) + }) +} + +type hasAutomaticShutdown struct { + calls *atomic.Int32 +} + +func (s *hasAutomaticShutdown) Shutdown(context.Context) error { + s.calls.Add(1) + return nil +} + +func TestOwnershipIsAlwaysExplicit(t *testing.T) { + t.Parallel() + + container := di.New() + var automaticCalls atomic.Int32 + var explicitCalls atomic.Int32 + require.NoError(t, di.Provide(container, func(di.Resolver) (*hasAutomaticShutdown, error) { + return &hasAutomaticShutdown{calls: &automaticCalls}, nil + })) + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testOther, error) { return &testOther{}, nil }, + func(context.Context, *testOther) error { + explicitCalls.Add(1) + return nil + }, + )) + + _, err := di.Resolve[*hasAutomaticShutdown](container) + require.NoError(t, err) + _, err = di.Resolve[*testOther](container) + require.NoError(t, err) + require.NoError(t, container.Shutdown(context.Background())) + + require.Zero(t, automaticCalls.Load()) + require.Equal(t, int32(1), explicitCalls.Load()) +} + +func TestShutdownUsesReverseDependencyOrderAndSkipsUnusedResources(t *testing.T) { + t.Parallel() + + container := di.New() + var dependentClosed atomic.Bool + var dependencyObservedOrder atomic.Bool + var unusedCalls atomic.Int32 + dependencyStarted := make(chan struct{}) + + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testDependency, error) { return &testDependency{}, nil }, + func(context.Context, *testDependency) error { + close(dependencyStarted) + dependencyObservedOrder.Store(dependentClosed.Load()) + return nil + }, + )) + require.NoError(t, di.ProvideResource(container, + func(resolver di.Resolver) (*testDependent, error) { + _, err := di.Resolve[*testDependency](resolver) + return &testDependent{}, err + }, + func(context.Context, *testDependent) error { + dependentClosed.Store(true) + return nil + }, + )) + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testUnused, error) { return &testUnused{}, nil }, + func(context.Context, *testUnused) error { + unusedCalls.Add(1) + return nil + }, + )) + + _, err := di.Resolve[*testDependent](container) + require.NoError(t, err) + require.NoError(t, container.Shutdown(context.Background())) + + select { + case <-dependencyStarted: + default: + require.Fail(t, "dependency cleanup was not called") + } + require.True(t, dependencyObservedOrder.Load()) + require.Zero(t, unusedCalls.Load()) +} + +func TestShutdownRunsIndependentResourcesConcurrently(t *testing.T) { + t.Parallel() + + container := di.New() + firstStarted := make(chan struct{}) + secondStarted := make(chan struct{}) + release := make(chan struct{}) + + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testDependency, error) { return &testDependency{}, nil }, + func(context.Context, *testDependency) error { + close(firstStarted) + <-release + return nil + }, + )) + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testOther, error) { return &testOther{}, nil }, + func(context.Context, *testOther) error { + close(secondStarted) + <-release + return nil + }, + )) + _, err := di.Resolve[*testDependency](container) + require.NoError(t, err) + _, err = di.Resolve[*testOther](container) + require.NoError(t, err) + + shutdownDone := make(chan error, 1) + go func() { shutdownDone <- container.Shutdown(context.Background()) }() + + requireStarted(t, firstStarted) + requireStarted(t, secondStarted) + close(release) + require.NoError(t, <-shutdownDone) +} + +func TestShutdownJoinsErrorsAndReturnsCachedResult(t *testing.T) { + t.Parallel() + + firstError := errors.New("first cleanup") + secondError := errors.New("second cleanup") + container := di.New() + var calls atomic.Int32 + + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testDependency, error) { return &testDependency{}, nil }, + func(context.Context, *testDependency) error { + calls.Add(1) + return firstError + }, + )) + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testOther, error) { return &testOther{}, nil }, + func(context.Context, *testOther) error { + calls.Add(1) + return secondError + }, + )) + _, err := di.Resolve[*testDependency](container) + require.NoError(t, err) + _, err = di.Resolve[*testOther](container) + require.NoError(t, err) + + firstResult := container.Shutdown(context.Background()) + secondResult := container.Shutdown(context.Background()) + require.ErrorIs(t, firstResult, firstError) + require.ErrorIs(t, firstResult, secondError) + require.Same(t, firstResult, secondResult) + require.Equal(t, int32(2), calls.Load()) + require.ErrorIs(t, func() error { + _, resolveErr := di.Resolve[*testDependency](container) + return resolveErr + }(), di.ErrClosed) + require.ErrorIs(t, di.ProvideValue(container, 1), di.ErrClosed) +} + +func TestShutdownConvertsCleanupPanicAndContinues(t *testing.T) { + t.Parallel() + + container := di.New() + var completed atomic.Bool + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testDependency, error) { return &testDependency{}, nil }, + func(context.Context, *testDependency) error { + panic("cleanup failed") + }, + )) + require.NoError(t, di.ProvideResource(container, + func(di.Resolver) (*testOther, error) { return &testOther{}, nil }, + func(context.Context, *testOther) error { + completed.Store(true) + return nil + }, + )) + _, err := di.Resolve[*testDependency](container) + require.NoError(t, err) + _, err = di.Resolve[*testOther](container) + require.NoError(t, err) + + err = container.Shutdown(context.Background()) + require.ErrorContains(t, err, "cleanup panic: cleanup failed") + require.True(t, completed.Load()) +} + +func TestShutdownWaitsForActiveResolutionAndRejectsNewOnes(t *testing.T) { + t.Parallel() + + container := di.New() + providerStarted := make(chan struct{}) + releaseProvider := make(chan struct{}) + require.NoError(t, di.Provide(container, func(di.Resolver) (*testDependency, error) { + close(providerStarted) + <-releaseProvider + return &testDependency{}, nil + })) + require.NoError(t, di.ProvideValue(container, testOther{})) + + resolveDone := make(chan error, 1) + go func() { + _, err := di.Resolve[*testDependency](container) + resolveDone <- err + }() + requireStarted(t, providerStarted) + + shutdownDone := make(chan error, 1) + go func() { shutdownDone <- container.Shutdown(context.Background()) }() + + require.Eventually(t, func() bool { + _, err := di.Resolve[testOther](container) + return errors.Is(err, di.ErrClosed) + }, time.Second, time.Millisecond) + close(releaseProvider) + + require.NoError(t, <-resolveDone) + require.NoError(t, <-shutdownDone) +} + +func requireStarted(t *testing.T, started <-chan struct{}) { + t.Helper() + select { + case <-started: + case <-time.After(2 * time.Second): + require.Fail(t, "operation did not start") + } +} + +func TestErrorsCarryReadableServiceDescriptions(t *testing.T) { + t.Parallel() + + container := di.New() + _, err := di.ResolveNamed[fmt.Stringer](container, "primary") + require.ErrorIs(t, err, di.ErrNotFound) + require.ErrorContains(t, err, "fmt.Stringer[primary]") +} diff --git a/di/doc.go b/di/doc.go new file mode 100644 index 0000000..aeedfaa --- /dev/null +++ b/di/doc.go @@ -0,0 +1,57 @@ +// Package di provides a small, type-safe dependency container for application +// composition roots. +// +// # Application integration +// +// The package intentionally exposes only lazy singletons, values, named +// registrations, explicit resource ownership, resolution, and shutdown. It +// does not expose its underlying DI engine. Application code should keep a +// Container inside its composition package (commonly internal/deps) and expose +// application-specific typed getters to entrypoints. +// +// Register the complete graph before resolving anything. A scenario constructor +// should eagerly resolve and cache the validated config and only the runtime +// roots that scenario needs. Fixed getters can then be error-free because +// construction already proved the graph. If construction fails after creating +// resources, the application owns a bounded rollback context and should join +// the build and shutdown errors. +// +// # Providers and resolution +// +// Registration and resolution are separate phases. The first call to Resolve +// or ResolveNamed seals the container; later registrations fail with +// ErrSealed. Providers must resolve their dependencies synchronously through +// the Resolver passed to them: +// +// di.Provide(container, func(r di.Resolver) (*Service, error) { +// repository, err := di.Resolve[Repository](r) +// if err != nil { +// return nil, err +// } +// return NewService(repository), nil +// }) +// +// A provider must not retain its Resolver or resolve through a captured root +// Container. Doing so either loses the dependency edge or uses an expired +// resolution scope. +// +// # Resource ownership +// +// ProvideResource and ProvideNamedResource explicitly transfer cleanup +// ownership to the container. Provide and ProvideValue never infer ownership +// from methods such as Close, Shutdown, Stop, or Sync. During Shutdown, +// dependents are stopped before their dependencies, while independent branches +// may be stopped concurrently. Only resources that were successfully built are +// stopped. +// +// The runtime owns the shutdown deadline and calls Container.Shutdown with a +// fresh context. Cleanup failures remain compatible with errors.Is through the +// joined shutdown result. +// +// # Testing +// +// Use a real Container in composition-root smoke tests to prove registration, +// selected implementations, eager roots, and explicit ownership. Do not mock +// the DI container in component unit tests; construct the component directly +// with mocks of its own consumer-side dependencies. +package di diff --git a/di/errors.go b/di/errors.go new file mode 100644 index 0000000..1ecc800 --- /dev/null +++ b/di/errors.go @@ -0,0 +1,20 @@ +package di + +import "errors" + +var ( + // ErrSealed means registration was attempted after resolution started. + ErrSealed = errors.New("di: container is sealed") + // ErrClosed means an operation was attempted after shutdown started. + ErrClosed = errors.New("di: container is closed") + // ErrDuplicate means the same typed registration already exists. + ErrDuplicate = errors.New("di: dependency already registered") + // ErrNotFound means no dependency was registered for the requested type and name. + ErrNotFound = errors.New("di: dependency not found") + // ErrCircularDependency means the provider graph contains a cycle. + ErrCircularDependency = errors.New("di: circular dependency") + // ErrInvalidArgument means a name, provider, cleanup, resolver, or context is invalid. + ErrInvalidArgument = errors.New("di: invalid argument") + // ErrNilService means a provider or value produced a nil dependency without an error. + ErrNilService = errors.New("di: nil dependency") +) diff --git a/di/example_test.go b/di/example_test.go new file mode 100644 index 0000000..172eecd --- /dev/null +++ b/di/example_test.go @@ -0,0 +1,74 @@ +package di_test + +import ( + "context" + "log" + "os" + + "github.com/devctllabs/go-libs/di" +) + +type exampleDatabase struct { + name string +} + +type exampleRepository struct { + database *exampleDatabase +} + +func Example() { + logger := log.New(os.Stdout, "", 0) + container := di.New() + if err := di.ProvideResource(container, + func(di.Resolver) (*exampleDatabase, error) { + return &exampleDatabase{name: "primary"}, nil + }, + func(_ context.Context, database *exampleDatabase) error { + logger.Println("close", database.name) + return nil + }, + ); err != nil { + log.Fatal(err) + } + if err := di.Provide(container, func(resolver di.Resolver) (*exampleRepository, error) { + database, err := di.Resolve[*exampleDatabase](resolver) + if err != nil { + return nil, err + } + return &exampleRepository{database: database}, nil + }); err != nil { + log.Fatal(err) + } + + repository, err := di.Resolve[*exampleRepository](container) + if err != nil { + log.Fatal(err) + } + logger.Println(repository.database.name) + if err := container.Shutdown(context.Background()); err != nil { + log.Fatal(err) + } + + // Output: + // primary + // close primary +} + +func ExampleProvideNamed() { + logger := log.New(os.Stdout, "", 0) + container := di.New() + if err := di.ProvideNamedValue(container, "primary", "postgres"); err != nil { + log.Fatal(err) + } + if err := di.ProvideNamedValue(container, "analytics", "clickhouse"); err != nil { + log.Fatal(err) + } + + value, err := di.ResolveNamed[string](container, "analytics") + if err != nil { + log.Fatal(err) + } + logger.Println(value) + + // Output: clickhouse +} diff --git a/di/go.mod b/di/go.mod new file mode 100644 index 0000000..755310a --- /dev/null +++ b/di/go.mod @@ -0,0 +1,15 @@ +module github.com/devctllabs/go-libs/di + +go 1.25.0 + +require ( + github.com/samber/do/v2 v2.1.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/samber/go-type-to-string v1.8.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/di/go.sum b/di/go.sum new file mode 100644 index 0000000..81227ac --- /dev/null +++ b/di/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/do/v2 v2.1.0 h1:lqCHn05XvY3VqwxvZDQPSkH+jIGWSVHUrSVLEbPOopo= +github.com/samber/do/v2 v2.1.0/go.mod h1:wJBoiaZcUZyGuraOhfz15b517ZMogGs+U03DvnqvT6Q= +github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= +github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/filesystem/copy.go b/filesystem/copy.go new file mode 100644 index 0000000..3bca0de --- /dev/null +++ b/filesystem/copy.go @@ -0,0 +1,197 @@ +package filesystem + +import ( + "context" + "io" + "io/fs" + "os" + "path" +) + +// Copy copies source into destination without overwriting existing files or +// symbolic links. Conflicts return an error matching fs.ErrExist; a nil source +// or invalid destination returns an error matching fs.ErrInvalid. Copy honors +// ctx but is not atomic and may leave a partial result on failure. +func (o *OS) Copy(ctx context.Context, source fs.FS, destination string) error { + return o.copyTree(ctx, source, destination, "copy", false) +} + +// Merge merges source into destination, replacing compatible files and +// symbolic links while preserving directories and unrelated entries. Type +// conflicts return an error matching fs.ErrExist; a nil source or invalid +// destination returns an error matching fs.ErrInvalid. Merge honors ctx but is +// not atomic and may leave a partial result on failure. +func (o *OS) Merge(ctx context.Context, source fs.FS, destination string) error { + return o.copyTree(ctx, source, destination, "merge", true) +} + +func (o *OS) copyTree(ctx context.Context, source fs.FS, destination, operation string, merge bool) error { + if _, err := operationName(ctx, operation, destination); err != nil { + return err + } + if source == nil { + return &fs.PathError{Op: operation, Path: ".", Err: fs.ErrInvalid} + } + + copy := treeCopy{o: o, ctx: ctx, source: source, destination: destination, operation: operation, merge: merge} + return fs.WalkDir(source, ".", copy.entry) +} + +type treeCopy struct { + o *OS + ctx context.Context + source fs.FS + destination string + operation string + merge bool +} + +func (c treeCopy) entry(sourceName string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := c.ctx.Err(); err != nil { + return err + } + destinationName := path.Join(c.destination, sourceName) + localDestination, err := localName(c.operation, destinationName) + if err != nil { + return err + } + switch entry.Type() { + case fs.ModeDir: + return c.copyDirectory(localDestination, destinationName) + case fs.ModeSymlink: + return c.copySymlink(sourceName, localDestination, destinationName) + case 0: + return c.o.copyFile(c.ctx, c.source, sourceName, destinationName, c.operation, c.merge) + default: + return &fs.PathError{Op: c.operation, Path: sourceName, Err: fs.ErrInvalid} + } +} + +func (c treeCopy) copyDirectory(localDestination, destinationName string) error { + mode, exists, err := c.o.destinationMode(localDestination) + if err != nil { + return err + } + if exists { + if !mode.IsDir() { + return conflict(c.operation, destinationName) + } + return nil + } + return c.o.root.MkdirAll(localDestination, 0o777) +} + +func (c treeCopy) copySymlink(sourceName, localDestination, destinationName string) error { + target, err := fs.ReadLink(c.source, sourceName) + if err != nil { + return err + } + if err := c.ctx.Err(); err != nil { + return err + } + mode, exists, err := c.o.destinationMode(localDestination) + if err != nil { + return err + } + if exists { + if !c.merge || mode&fs.ModeSymlink == 0 { + return conflict(c.operation, destinationName) + } + if err := c.o.root.Remove(localDestination); err != nil { + return err + } + } + return c.o.root.Symlink(target, localDestination) +} + +func (o *OS) copyFile( + ctx context.Context, + source fs.FS, + sourceName, destinationName, operation string, + merge bool, +) error { + input, err := source.Open(sourceName) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + + info, err := input.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return &fs.PathError{Op: operation, Path: sourceName, Err: fs.ErrInvalid} + } + if err := ctx.Err(); err != nil { + return err + } + + localDestination, err := localName(operation, destinationName) + if err != nil { + return err + } + destinationMode, exists, err := o.destinationMode(localDestination) + if err != nil { + return err + } + if exists && (!merge || !destinationMode.IsRegular()) { + return conflict(operation, destinationName) + } + + flags := os.O_CREATE | os.O_EXCL | os.O_WRONLY + if exists { + flags = os.O_TRUNC | os.O_WRONLY + } + output, err := o.root.OpenFile( + localDestination, + flags, + 0o666|info.Mode().Perm()&0o111, + ) + if err != nil { + return err + } + + _, copyErr := io.Copy(output, contextReader{ctx: ctx, reader: input}) + closeErr := output.Close() + if copyErr != nil { + return &fs.PathError{Op: operation, Path: destinationName, Err: copyErr} + } + if closeErr != nil { + return &fs.PathError{Op: operation, Path: destinationName, Err: closeErr} + } + + return nil +} + +func (o *OS) destinationMode(localName string) (fs.FileMode, bool, error) { + info, err := o.root.Lstat(localName) + if err == nil { + return info.Mode(), true, nil + } + if os.IsNotExist(err) { + return 0, false, nil + } + + return 0, false, err +} + +func conflict(operation, name string) error { + return &fs.PathError{Op: operation, Path: name, Err: fs.ErrExist} +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(buffer []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + + return r.reader.Read(buffer) +} diff --git a/filesystem/copy_test.go b/filesystem/copy_test.go new file mode 100644 index 0000000..53ce532 --- /dev/null +++ b/filesystem/copy_test.go @@ -0,0 +1,147 @@ +package filesystem_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + "testing/fstest" + + "github.com/devctllabs/go-libs/filesystem" + "github.com/stretchr/testify/require" +) + +var ( + _ filesystem.Copier = (*filesystem.OS)(nil) + _ fs.ReadLinkFS = (*filesystem.OS)(nil) +) + +func TestOSCopiesFilesystemTree(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + source := fstest.MapFS{ + "DOMAIN.md": {Data: []byte("domain")}, + "agents/worker.toml": {Data: []byte("agent")}, + } + + require.NoError(t, disk.Copy(context.Background(), source, "packages/sample")) + + domain, err := fs.ReadFile(disk, "packages/sample/DOMAIN.md") + require.NoError(t, err) + require.Equal(t, "domain", string(domain)) + + agent, err := fs.ReadFile(disk, "packages/sample/agents/worker.toml") + require.NoError(t, err) + require.Equal(t, "agent", string(agent)) +} + +func TestOSCopyRejectsExistingFile(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + ctx := context.Background() + require.NoError(t, disk.MkdirAll(ctx, "target", 0o750)) + require.NoError(t, disk.WriteFile(ctx, "target/file.txt", []byte("existing"), 0o600)) + + err = disk.Copy(ctx, fstest.MapFS{"file.txt": {Data: []byte("source")}}, "target") + + require.ErrorIs(t, err, fs.ErrExist) + data, readErr := fs.ReadFile(disk, "target/file.txt") + require.NoError(t, readErr) + require.Equal(t, "existing", string(data)) +} + +func TestOSCopyRejectsDirectoryOverFile(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + require.NoError(t, disk.WriteFile(context.Background(), "target", nil, 0o600)) + err = disk.Copy(context.Background(), fstest.MapFS{ + "file.txt": {Data: []byte("source")}, + }, "target") + + require.ErrorIs(t, err, fs.ErrExist) +} + +func TestOSCopyHonorsContextAndValidatesDestination(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + require.ErrorIs(t, disk.Copy(ctx, fstest.MapFS{}, "target"), context.Canceled) + + err = disk.Copy(context.Background(), fstest.MapFS{}, "../target") + var pathErr *fs.PathError + require.ErrorAs(t, err, &pathErr) + require.ErrorIs(t, err, fs.ErrInvalid) +} + +func TestOSCopyMatchesStandardPermissions(t *testing.T) { + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + source := fstest.MapFS{"tool": {Data: []byte("tool"), Mode: 0o751}} + require.NoError(t, disk.Copy(context.Background(), source, "actual")) + require.NoError(t, os.CopyFS(filepath.Join(root, "reference"), source)) + + actual, err := os.Stat(filepath.Join(root, "actual", "tool")) + require.NoError(t, err) + reference, err := os.Stat(filepath.Join(root, "reference", "tool")) + require.NoError(t, err) + require.Equal(t, reference.Mode().Perm(), actual.Mode().Perm()) +} + +func TestOSCopyHandlesSymlinksLikeCopyFS(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks may require elevated Windows privileges") + } + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + source := fstest.MapFS{ + "file.txt": {Data: []byte("content")}, + "link.txt": {Data: []byte("file.txt"), Mode: fs.ModeSymlink}, + } + require.NoError(t, disk.Copy(context.Background(), source, "target")) + + target, err := fs.ReadLink(disk, "target/link.txt") + require.NoError(t, err) + require.Equal(t, "file.txt", target) +} + +func TestOSCopyRejectsSpecialFiles(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + err = disk.Copy(context.Background(), fstest.MapFS{ + "pipe": {Mode: fs.ModeNamedPipe}, + }, "target") + + require.ErrorIs(t, err, fs.ErrInvalid) +} diff --git a/filesystem/doc.go b/filesystem/doc.go new file mode 100644 index 0000000..5a52bb6 --- /dev/null +++ b/filesystem/doc.go @@ -0,0 +1,38 @@ +// Package filesystem provides rooted operating system filesystem operations +// that compose with the standard io/fs package. +// +// # Integration +// +// Read sources use fs.FS directly, so callers can supply embed.FS, os.DirFS, +// fstest.MapFS, or another implementation without an adapter. Open returns an +// OS rooted at an existing operating system directory. OS implements fs.FS, +// fs.ReadLinkFS, Copier, Merger, Writer, Remover, and io.Closer. +// +// Infrastructure components that directly coordinate filesystem mechanics may +// accept only the narrow interface they use, such as Copier or Writer, and +// receive *OS from their application composition root. Service and usecase +// packages should usually depend on consumer-owned application capabilities +// such as PackageStore or Workspace instead of exposing generic filesystem +// operations, paths, layout, or symlink policy as business contracts. +// +// Do not inject a filesystem abstraction merely to avoid using os in a concrete +// filesystem repository. Test that repository against t.TempDir unless it has +// a real alternate backend or coordinates behavior that benefits from a lower +// seam. Generated gomock implementations of this package's capability +// interfaces are available in the filesystem/mocks subpackage. +// +// # Root and operation semantics +// +// The caller must close an OS. All names used after Open must satisfy +// fs.ValidPath and cannot escape the root through parent traversal or symbolic +// links. Mutating operations honor a canceled context before changing the +// destination; Copy and Merge also check it while processing a tree. +// +// Copy preserves existing files, while Merge replaces regular files and +// symbolic links only when their destination types match. Both operations +// preserve unrelated destination entries, copy symbolic links as links, and +// may leave a partial result if they fail or their context is canceled. They +// do not provide atomic replacement or rollback. New regular files follow +// os.CopyFS permission semantics; replacing an existing regular file preserves +// its destination permissions. +package filesystem diff --git a/filesystem/example_test.go b/filesystem/example_test.go new file mode 100644 index 0000000..afa0caf --- /dev/null +++ b/filesystem/example_test.go @@ -0,0 +1,56 @@ +package filesystem_test + +import ( + "context" + "io/fs" + "log" + "os" + "testing/fstest" + + "github.com/devctllabs/go-libs/filesystem" +) + +func ExampleOS_Copy() { + logger := log.New(os.Stdout, "", 0) + root, err := os.MkdirTemp("", "filesystem-example-") + if err != nil { + log.Fatal(err) + } + defer func() { _ = os.RemoveAll(root) }() + + disk, err := filesystem.Open(root) + if err != nil { + log.Print(err) + return + } + defer func() { _ = disk.Close() }() + + source := fstest.MapFS{ + "config/app.toml": {Data: []byte("version = 1")}, + } + if err := copyPackage(context.Background(), disk, source); err != nil { + log.Print(err) + return + } + if err := disk.Merge(context.Background(), fstest.MapFS{ + "config/app.toml": {Data: []byte("version = 2")}, + }, "package"); err != nil { + log.Print(err) + return + } + + data, err := fs.ReadFile(disk, "package/config/app.toml") + if err != nil { + log.Print(err) + return + } + logger.Println(string(data)) + + // Output: version = 2 +} + +// copyPackage accepts only the filesystem capability it needs. Application +// services would normally depend on a higher-level, consumer-owned interface. +func copyPackage(ctx context.Context, destination filesystem.Copier, source fs.FS) error { + return destination.Copy(ctx, source, "package") +} diff --git a/filesystem/go.mod b/filesystem/go.mod new file mode 100644 index 0000000..9e6e4bf --- /dev/null +++ b/filesystem/go.mod @@ -0,0 +1,19 @@ +module github.com/devctllabs/go-libs/filesystem + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 +) + +tool go.uber.org/mock/mockgen + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/filesystem/go.sum b/filesystem/go.sum new file mode 100644 index 0000000..746df6c --- /dev/null +++ b/filesystem/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/filesystem/interfaces.go b/filesystem/interfaces.go new file mode 100644 index 0000000..84b947d --- /dev/null +++ b/filesystem/interfaces.go @@ -0,0 +1,51 @@ +package filesystem + +import ( + "context" + "io/fs" +) + +//go:generate go tool mockgen -destination mocks/filesystem.gen.go -package mocks . Copier,Merger,Writer,Remover + +// Copier copies filesystem trees without overwriting existing files. It is a +// narrow infrastructure capability; application services should normally use a +// consumer-owned interface that describes their storage operation. +type Copier interface { + // Copy copies source into destination. Existing directories are reused, but + // an existing file or symbolic link causes an error matching fs.ErrExist. + // A nil source or invalid destination causes an error matching fs.ErrInvalid. + // The operation honors ctx but is not atomic and may leave a partial result + // on failure or cancellation. + Copy(ctx context.Context, source fs.FS, destination string) error +} + +// Merger merges filesystem trees while preserving compatible destination +// entries. +type Merger interface { + // Merge copies source into destination. Regular files and symbolic links of + // the same type are replaced, directories are reused, and unrelated entries + // are preserved. A type mismatch causes an error matching fs.ErrExist. The + // operation honors ctx but is not atomic and may leave a partial result on + // failure or cancellation. A nil source or invalid destination causes an + // error matching fs.ErrInvalid. + Merge(ctx context.Context, source fs.FS, destination string) error +} + +// Writer creates directories and writes files within a filesystem root. +type Writer interface { + // MkdirAll creates name and any missing parents with perm, subject to the + // process umask. Name must satisfy fs.ValidPath. A canceled ctx is returned + // before the filesystem is changed. + MkdirAll(ctx context.Context, name string, perm fs.FileMode) error + // WriteFile writes data to name, creating or truncating the file. It does + // not create missing parent directories. Name must satisfy fs.ValidPath. A + // canceled ctx is returned before the filesystem is changed. + WriteFile(ctx context.Context, name string, data []byte, perm fs.FileMode) error +} + +// Remover removes entries within a filesystem root. +type Remover interface { + // Remove removes the file or empty directory named name. Name must satisfy + // fs.ValidPath. A canceled ctx is returned before the filesystem is changed. + Remove(ctx context.Context, name string) error +} diff --git a/filesystem/merge_test.go b/filesystem/merge_test.go new file mode 100644 index 0000000..1880b34 --- /dev/null +++ b/filesystem/merge_test.go @@ -0,0 +1,157 @@ +package filesystem_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + "testing/fstest" + + "github.com/devctllabs/go-libs/filesystem" + "github.com/stretchr/testify/require" +) + +var _ filesystem.Merger = (*filesystem.OS)(nil) + +func TestOSMergesCompatibleFilesystemTree(t *testing.T) { + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + ctx := context.Background() + require.NoError(t, disk.MkdirAll(ctx, "target", 0o750)) + require.NoError(t, disk.WriteFile(ctx, "target/file.txt", []byte("old"), 0o600)) + require.NoError(t, disk.WriteFile(ctx, "target/extra.txt", []byte("extra"), 0o600)) + + source := fstest.MapFS{ + "file.txt": {Data: []byte("new"), Mode: 0o755}, + "nested/new.txt": {Data: []byte("nested")}, + } + require.NoError(t, disk.Merge(ctx, source, "target")) + + updated, err := fs.ReadFile(disk, "target/file.txt") + require.NoError(t, err) + require.Equal(t, "new", string(updated)) + extra, err := fs.ReadFile(disk, "target/extra.txt") + require.NoError(t, err) + require.Equal(t, "extra", string(extra)) + nested, err := fs.ReadFile(disk, "target/nested/new.txt") + require.NoError(t, err) + require.Equal(t, "nested", string(nested)) + + info, err := os.Stat(filepath.Join(root, "target", "file.txt")) + require.NoError(t, err) + require.Equal(t, fs.FileMode(0o600), info.Mode().Perm()) +} + +func TestOSMergeRejectsTypeMismatches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source fstest.MapFS + preparePath func(t *testing.T, disk *filesystem.OS) + }{ + { + name: "file over directory", + source: fstest.MapFS{"entry": {Data: []byte("file")}}, + preparePath: func(t *testing.T, disk *filesystem.OS) { + require.NoError(t, disk.MkdirAll(context.Background(), "target/entry", 0o750)) + }, + }, + { + name: "directory over file", + source: fstest.MapFS{"entry": {Mode: fs.ModeDir}}, + preparePath: func(t *testing.T, disk *filesystem.OS) { + require.NoError(t, disk.MkdirAll(context.Background(), "target", 0o750)) + require.NoError(t, disk.WriteFile(context.Background(), "target/entry", nil, 0o600)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + tt.preparePath(t, disk) + + err = disk.Merge(context.Background(), tt.source, "target") + + require.ErrorIs(t, err, fs.ErrExist) + }) + } +} + +func TestOSMergeReplacesSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks may require elevated Windows privileges") + } + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + require.NoError(t, disk.MkdirAll(context.Background(), "target", 0o750)) + require.NoError(t, os.Symlink("old.txt", filepath.Join(root, "target", "link.txt"))) + source := fstest.MapFS{ + "link.txt": {Data: []byte("new.txt"), Mode: fs.ModeSymlink}, + } + + require.NoError(t, disk.Merge(context.Background(), source, "target")) + target, err := fs.ReadLink(disk, "target/link.txt") + require.NoError(t, err) + require.Equal(t, "new.txt", target) +} + +func TestOSMergeRejectsSymlinkTypeMismatch(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks may require elevated Windows privileges") + } + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + require.NoError(t, disk.MkdirAll(context.Background(), "target", 0o750)) + require.NoError(t, disk.WriteFile(context.Background(), "target/link.txt", nil, 0o600)) + source := fstest.MapFS{ + "link.txt": {Data: []byte("file.txt"), Mode: fs.ModeSymlink}, + } + + err = disk.Merge(context.Background(), source, "target") + + require.ErrorIs(t, err, fs.ErrExist) +} + +func TestOSMergeRejectsFileOverSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks may require elevated Windows privileges") + } + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + require.NoError(t, disk.MkdirAll(context.Background(), "target", 0o750)) + require.NoError(t, os.Symlink("other.txt", filepath.Join(root, "target", "file.txt"))) + + err = disk.Merge(context.Background(), fstest.MapFS{ + "file.txt": {Data: []byte("source")}, + }, "target") + + require.ErrorIs(t, err, fs.ErrExist) +} diff --git a/filesystem/mocks/filesystem.gen.go b/filesystem/mocks/filesystem.gen.go new file mode 100644 index 0000000..7383315 --- /dev/null +++ b/filesystem/mocks/filesystem.gen.go @@ -0,0 +1,184 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/filesystem (interfaces: Copier,Merger,Writer,Remover) +// +// Generated by this command: +// +// mockgen -destination mocks/filesystem.gen.go -package mocks . Copier,Merger,Writer,Remover +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + fs "io/fs" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockCopier is a mock of Copier interface. +type MockCopier struct { + ctrl *gomock.Controller + recorder *MockCopierMockRecorder + isgomock struct{} +} + +// MockCopierMockRecorder is the mock recorder for MockCopier. +type MockCopierMockRecorder struct { + mock *MockCopier +} + +// NewMockCopier creates a new mock instance. +func NewMockCopier(ctrl *gomock.Controller) *MockCopier { + mock := &MockCopier{ctrl: ctrl} + mock.recorder = &MockCopierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCopier) EXPECT() *MockCopierMockRecorder { + return m.recorder +} + +// Copy mocks base method. +func (m *MockCopier) Copy(ctx context.Context, source fs.FS, destination string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Copy", ctx, source, destination) + ret0, _ := ret[0].(error) + return ret0 +} + +// Copy indicates an expected call of Copy. +func (mr *MockCopierMockRecorder) Copy(ctx, source, destination any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Copy", reflect.TypeOf((*MockCopier)(nil).Copy), ctx, source, destination) +} + +// MockMerger is a mock of Merger interface. +type MockMerger struct { + ctrl *gomock.Controller + recorder *MockMergerMockRecorder + isgomock struct{} +} + +// MockMergerMockRecorder is the mock recorder for MockMerger. +type MockMergerMockRecorder struct { + mock *MockMerger +} + +// NewMockMerger creates a new mock instance. +func NewMockMerger(ctrl *gomock.Controller) *MockMerger { + mock := &MockMerger{ctrl: ctrl} + mock.recorder = &MockMergerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMerger) EXPECT() *MockMergerMockRecorder { + return m.recorder +} + +// Merge mocks base method. +func (m *MockMerger) Merge(ctx context.Context, source fs.FS, destination string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Merge", ctx, source, destination) + ret0, _ := ret[0].(error) + return ret0 +} + +// Merge indicates an expected call of Merge. +func (mr *MockMergerMockRecorder) Merge(ctx, source, destination any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Merge", reflect.TypeOf((*MockMerger)(nil).Merge), ctx, source, destination) +} + +// MockWriter is a mock of Writer interface. +type MockWriter struct { + ctrl *gomock.Controller + recorder *MockWriterMockRecorder + isgomock struct{} +} + +// MockWriterMockRecorder is the mock recorder for MockWriter. +type MockWriterMockRecorder struct { + mock *MockWriter +} + +// NewMockWriter creates a new mock instance. +func NewMockWriter(ctrl *gomock.Controller) *MockWriter { + mock := &MockWriter{ctrl: ctrl} + mock.recorder = &MockWriterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWriter) EXPECT() *MockWriterMockRecorder { + return m.recorder +} + +// MkdirAll mocks base method. +func (m *MockWriter) MkdirAll(ctx context.Context, name string, perm fs.FileMode) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MkdirAll", ctx, name, perm) + ret0, _ := ret[0].(error) + return ret0 +} + +// MkdirAll indicates an expected call of MkdirAll. +func (mr *MockWriterMockRecorder) MkdirAll(ctx, name, perm any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MkdirAll", reflect.TypeOf((*MockWriter)(nil).MkdirAll), ctx, name, perm) +} + +// WriteFile mocks base method. +func (m *MockWriter) WriteFile(ctx context.Context, name string, data []byte, perm fs.FileMode) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WriteFile", ctx, name, data, perm) + ret0, _ := ret[0].(error) + return ret0 +} + +// WriteFile indicates an expected call of WriteFile. +func (mr *MockWriterMockRecorder) WriteFile(ctx, name, data, perm any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteFile", reflect.TypeOf((*MockWriter)(nil).WriteFile), ctx, name, data, perm) +} + +// MockRemover is a mock of Remover interface. +type MockRemover struct { + ctrl *gomock.Controller + recorder *MockRemoverMockRecorder + isgomock struct{} +} + +// MockRemoverMockRecorder is the mock recorder for MockRemover. +type MockRemoverMockRecorder struct { + mock *MockRemover +} + +// NewMockRemover creates a new mock instance. +func NewMockRemover(ctrl *gomock.Controller) *MockRemover { + mock := &MockRemover{ctrl: ctrl} + mock.recorder = &MockRemoverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRemover) EXPECT() *MockRemoverMockRecorder { + return m.recorder +} + +// Remove mocks base method. +func (m *MockRemover) Remove(ctx context.Context, name string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", ctx, name) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockRemoverMockRecorder) Remove(ctx, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockRemover)(nil).Remove), ctx, name) +} diff --git a/filesystem/mutation.go b/filesystem/mutation.go new file mode 100644 index 0000000..13d4859 --- /dev/null +++ b/filesystem/mutation.go @@ -0,0 +1,50 @@ +package filesystem + +import ( + "context" + "io/fs" +) + +// MkdirAll creates name and any missing parents within the root using perm, +// subject to the process umask. Name must satisfy fs.ValidPath. If ctx is +// already canceled, MkdirAll returns its error without changing the filesystem. +func (o *OS) MkdirAll(ctx context.Context, name string, perm fs.FileMode) error { + local, err := operationName(ctx, "mkdirall", name) + if err != nil { + return err + } + + return o.root.MkdirAll(local, perm) +} + +// WriteFile writes data to name within the root, creating or truncating the +// file without creating missing parent directories. Name must satisfy +// fs.ValidPath. If ctx is already canceled, WriteFile returns its error without +// changing the filesystem. +func (o *OS) WriteFile(ctx context.Context, name string, data []byte, perm fs.FileMode) error { + local, err := operationName(ctx, "writefile", name) + if err != nil { + return err + } + + return o.root.WriteFile(local, data, perm) +} + +// Remove removes the file or empty directory named name within the root. Name +// must satisfy fs.ValidPath. If ctx is already canceled, Remove returns its +// error without changing the filesystem. +func (o *OS) Remove(ctx context.Context, name string) error { + local, err := operationName(ctx, "remove", name) + if err != nil { + return err + } + + return o.root.Remove(local) +} + +func operationName(ctx context.Context, op, name string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return localName(op, name) +} diff --git a/filesystem/mutation_test.go b/filesystem/mutation_test.go new file mode 100644 index 0000000..cc80748 --- /dev/null +++ b/filesystem/mutation_test.go @@ -0,0 +1,117 @@ +package filesystem_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/devctllabs/go-libs/filesystem" + "github.com/stretchr/testify/require" +) + +var ( + _ filesystem.Writer = (*filesystem.OS)(nil) + _ filesystem.Remover = (*filesystem.OS)(nil) +) + +func TestOSWritesAndRemovesRootedEntries(t *testing.T) { + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + ctx := context.Background() + require.NoError(t, disk.MkdirAll(ctx, "nested/empty", 0o750)) + require.NoError(t, disk.WriteFile(ctx, "nested/file.txt", []byte("content"), 0o600)) + + data, err := fs.ReadFile(disk, "nested/file.txt") + require.NoError(t, err) + require.Equal(t, "content", string(data)) + + require.NoError(t, disk.Remove(ctx, "nested/file.txt")) + _, err = fs.Stat(disk, "nested/file.txt") + require.ErrorIs(t, err, fs.ErrNotExist) + + require.NoError(t, disk.Remove(ctx, "nested/empty")) + _, err = fs.Stat(disk, "nested/empty") + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestOSWriteFileDoesNotCreateParentDirectories(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + err = disk.WriteFile(context.Background(), "missing/file.txt", []byte("content"), 0o600) + + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestOSMutationsHonorCanceledContext(t *testing.T) { + t.Parallel() + + root := t.TempDir() + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + require.ErrorIs(t, disk.MkdirAll(ctx, "directory", 0o750), context.Canceled) + require.ErrorIs(t, disk.WriteFile(ctx, "file.txt", []byte("content"), 0o600), context.Canceled) + require.ErrorIs(t, disk.Remove(ctx, "file.txt"), context.Canceled) + require.NoFileExists(t, filepath.Join(root, "file.txt")) + require.NoDirExists(t, filepath.Join(root, "directory")) +} + +func TestOSMutationsRejectInvalidPaths(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + ctx := context.Background() + for _, operation := range []func() error{ + func() error { return disk.MkdirAll(ctx, "../directory", 0o750) }, + func() error { return disk.WriteFile(ctx, "/file.txt", nil, 0o600) }, + func() error { return disk.Remove(ctx, "") }, + } { + err := operation() + var pathErr *fs.PathError + require.ErrorAs(t, err, &pathErr) + require.ErrorIs(t, err, fs.ErrInvalid) + } +} + +func TestOSMutationsCannotEscapeThroughSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks may require elevated Windows privileges") + } + t.Parallel() + + root := t.TempDir() + outside := t.TempDir() + outsideFile := filepath.Join(outside, "file.txt") + require.NoError(t, os.WriteFile(outsideFile, []byte("unchanged"), 0o600)) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "escape"))) + + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + err = disk.WriteFile(context.Background(), "escape/file.txt", []byte("changed"), 0o600) + + require.Error(t, err) + data, readErr := os.ReadFile(outsideFile) + require.NoError(t, readErr) + require.Equal(t, "unchanged", string(data)) +} diff --git a/filesystem/os.go b/filesystem/os.go new file mode 100644 index 0000000..589755f --- /dev/null +++ b/filesystem/os.go @@ -0,0 +1,66 @@ +package filesystem + +import ( + "io/fs" + "os" + "path/filepath" +) + +// OS provides read and mutation access to an operating system directory through +// rooted paths. Create it with Open and close it when no longer needed. +type OS struct { + root *os.Root + fsys fs.FS +} + +// Open opens root, which must be an existing operating system directory, as a +// rooted filesystem. The caller must close the returned OS. +func Open(root string) (*OS, error) { + osRoot, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + + return &OS{root: osRoot, fsys: osRoot.FS()}, nil +} + +// Open opens name for reading within the root. Name must satisfy fs.ValidPath. +func (o *OS) Open(name string) (fs.File, error) { + return o.fsys.Open(name) +} + +// Lstat returns information about name without following a final symbolic +// link. Name must satisfy fs.ValidPath. +func (o *OS) Lstat(name string) (fs.FileInfo, error) { + local, err := localName("lstat", name) + if err != nil { + return nil, err + } + + return o.root.Lstat(local) +} + +// ReadLink returns the destination of the symbolic link named name. Name must +// satisfy fs.ValidPath. +func (o *OS) ReadLink(name string) (string, error) { + local, err := localName("readlink", name) + if err != nil { + return "", err + } + + return o.root.Readlink(local) +} + +// Close releases the operating system root. Callers must not use OS after Close. +func (o *OS) Close() error { + return o.root.Close() +} + +func localName(op, name string) (string, error) { + local, err := filepath.Localize(name) + if err != nil { + return "", &fs.PathError{Op: op, Path: name, Err: fs.ErrInvalid} + } + + return local, nil +} diff --git a/filesystem/os_test.go b/filesystem/os_test.go new file mode 100644 index 0000000..bdae46c --- /dev/null +++ b/filesystem/os_test.go @@ -0,0 +1,46 @@ +package filesystem_test + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/devctllabs/go-libs/filesystem" + "github.com/stretchr/testify/require" +) + +func TestOSReadsFromRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o600)) + + disk, err := filesystem.Open(root) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, disk.Close()) }) + + data, err := fs.ReadFile(disk, "file.txt") + require.NoError(t, err) + require.Equal(t, "content", string(data)) +} + +func TestOpenRequiresExistingRoot(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(filepath.Join(t.TempDir(), "missing")) + + require.Nil(t, disk) + require.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestOSCannotReadAfterClose(t *testing.T) { + t.Parallel() + + disk, err := filesystem.Open(t.TempDir()) + require.NoError(t, err) + require.NoError(t, disk.Close()) + + _, err = fs.ReadFile(disk, "file.txt") + require.Error(t, err) +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..208fce8 --- /dev/null +++ b/go.work @@ -0,0 +1,25 @@ +go 1.25.0 + +use ( + ./codexapp + ./config + ./debugserver + ./di + ./filesystem + ./health + ./healthotel + ./healthserver + ./healthzap + ./lifecycle + ./log + ./oapivalidator + ./postgresdb + ./sqlitedb + ./telemetry + ./txmanager +) + +replace ( + github.com/devctllabs/go-libs/health v0.1.0 => ./health + github.com/devctllabs/go-libs/txmanager v0.1.0 => ./txmanager +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..0022038 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,125 @@ +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= +github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/blackwell-systems/gcf-go v1.2.2/go.mod h1:E4fW1kxdrIoWxlI4iwZL8mh7BvdLTkE88NyijtGGcZc= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kataras/blocks v0.0.8/go.mod h1:9Jm5zx6BB+06NwA+OhTbHW1xkMOYxahnqTN5DveZ2Yg= +github.com/kataras/golog v0.1.11/go.mod h1:mAkt1vbPowFUuUGvexyQ5NFW6djEgGyxQBIARJ0AH4A= +github.com/kataras/iris/v12 v12.2.11/go.mod h1:uMAeX8OqG9vqdhyrIPv8Lajo/wXTtAF43wchP9WHt2w= +github.com/kataras/pio v0.0.13/go.mod h1:k3HNuSw+eJ8Pm2lA4lRhg3DiCjVgHlP8hmXApSej3oM= +github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= +github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= +github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= +github.com/moby/sys/mount v0.3.4/go.mod h1:KcQJMbQdJHPlq5lcYT+/CjatWM4PuxKe+XLSVS4J6Os= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM= +github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/health/doc.go b/health/doc.go new file mode 100644 index 0000000..ab86fb9 --- /dev/null +++ b/health/doc.go @@ -0,0 +1,12 @@ +// Package health coordinates transport-neutral liveness and readiness probes. +// +// Liveness is always process-local and never invokes component checks. Readiness runs explicitly +// registered critical and non-critical checks concurrently under one deadline. Critical failures +// make readiness fail; non-critical failures remain visible in reports and observers without +// removing the instance from traffic. +// +// Probes are immutable after New and start workers only for an active Readiness call. Checkers must +// honor context cancellation; a checker that ignores it can continue running after Readiness has +// returned. Observers may be called concurrently by separate Readiness calls and must return +// promptly. Raw checker errors are available only to observers, never to transport-safe reports. +package health diff --git a/health/example_test.go b/health/example_test.go new file mode 100644 index 0000000..7248e0e --- /dev/null +++ b/health/example_test.go @@ -0,0 +1,22 @@ +package health_test + +import ( + "context" + "log" + "os" + + "github.com/devctllabs/go-libs/health" +) + +func Example() { + logger := log.New(os.Stdout, "", 0) + probes, _ := health.New( + health.Critical("database", health.CheckFunc(func(context.Context) error { return nil })), + ) + logger.Println(probes.Liveness().Status) + logger.Println(probes.Readiness(context.Background()).Status) + + // Output: + // ok + // ok +} diff --git a/health/go.mod b/health/go.mod new file mode 100644 index 0000000..0e37cf7 --- /dev/null +++ b/health/go.mod @@ -0,0 +1,19 @@ +module github.com/devctllabs/go-libs/health + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 +) + +tool go.uber.org/mock/mockgen + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/health/go.sum b/health/go.sum new file mode 100644 index 0000000..746df6c --- /dev/null +++ b/health/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000..2e703d6 --- /dev/null +++ b/health/health.go @@ -0,0 +1,316 @@ +package health + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + "strings" + "time" +) + +//go:generate go tool mockgen -destination mocks/health.gen.go -package mocks . Checker,Observer + +// Checker reports whether one registered component is healthy. +type Checker interface { + // Check performs one bounded health observation and must honor ctx cancellation. + Check(ctx context.Context) error +} + +// CheckFunc adapts a function to Checker. +type CheckFunc func(ctx context.Context) error + +// Check calls f with ctx. +func (f CheckFunc) Check(ctx context.Context) error { + return f(ctx) +} + +// Outcome classifies how one check invocation completed. +type Outcome string + +const ( + OutcomeOK Outcome = "ok" + OutcomeError Outcome = "error" + OutcomeTimeout Outcome = "timeout" +) + +// Observation contains the internal result supplied to diagnostic adapters. +type Observation struct { + Name string + Critical bool + Outcome Outcome + Duration time.Duration + Err error +} + +// Observer receives completed component check observations. +type Observer interface { + // Observe records observation and must return promptly. + Observe(ctx context.Context, observation Observation) +} + +// ObserverFunc adapts a function to Observer. +type ObserverFunc func(ctx context.Context, observation Observation) + +// Observe calls f with observation. +func (f ObserverFunc) Observe(ctx context.Context, observation Observation) { + f(ctx, observation) +} + +// Status is the binary outcome of a Kubernetes probe or component check. +type Status string + +const ( + StatusOK Status = "ok" + StatusFail Status = "fail" +) + +// Report is the transport-neutral result of a probe evaluation. +type Report struct { + Status Status + Checks []CheckResult +} + +// CheckResult is the safe, transport-neutral status of one component check. +type CheckResult struct { + Name string + Status Status + Critical bool +} + +type registration struct { + name string + critical bool + checker Checker +} + +type config struct { + checkTimeout time.Duration + checks []registration + observers []Observer +} + +// Option configures Probes during construction. +type Option interface { + apply(*config) error +} + +type optionFunc func(*config) error + +func (f optionFunc) apply(cfg *config) error { + return f(cfg) +} + +// Critical registers checker as required for readiness. +func Critical(name string, checker Checker) Option { + return register(name, true, checker) +} + +// NonCritical registers checker for diagnostics without affecting readiness. +func NonCritical(name string, checker Checker) Option { + return register(name, false, checker) +} + +// WithCheckTimeout replaces the one-second deadline shared by readiness checks. +func WithCheckTimeout(timeout time.Duration) Option { + return optionFunc(func(cfg *config) error { + if timeout <= 0 { + return errors.New("health: check timeout must be positive") + } + cfg.checkTimeout = timeout + return nil + }) +} + +// WithObserver appends observer to the completed-check notification list. +func WithObserver(observer Observer) Option { + return optionFunc(func(cfg *config) error { + if observer == nil { + return errors.New("health: observer must not be nil") + } + cfg.observers = append(cfg.observers, observer) + return nil + }) +} + +func register(name string, critical bool, checker Checker) Option { + return optionFunc(func(cfg *config) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("health: check name must not be blank") + } + if checker == nil { + return fmt.Errorf("health: checker %q must not be nil", name) + } + for _, check := range cfg.checks { + if check.name == name { + return fmt.Errorf("health: duplicate check name %q", name) + } + } + cfg.checks = append(cfg.checks, registration{name: name, critical: critical, checker: checker}) + return nil + }) +} + +// Probes owns one instance's health state and checks. +type Probes struct { + checkTimeout time.Duration + checks []registration + observers []Observer +} + +// New constructs an independent probe set. +func New(options ...Option) (*Probes, error) { + cfg := config{checkTimeout: time.Second} + for _, option := range options { + if option == nil { + continue + } + if err := option.apply(&cfg); err != nil { + return nil, err + } + } + return &Probes{ + checkTimeout: cfg.checkTimeout, + checks: append([]registration(nil), cfg.checks...), + observers: append([]Observer(nil), cfg.observers...), + }, nil +} + +// Liveness reports whether the health handler itself can respond. +func (*Probes) Liveness() Report { + return Report{Status: StatusOK} +} + +// CheckTimeout returns the deadline applied to one readiness aggregation. +func (p *Probes) CheckTimeout() time.Duration { + return p.checkTimeout +} + +// Readiness checks every registered component. +func (p *Probes) Readiness(ctx context.Context) Report { + checkCtx, cancel := context.WithTimeout(ctx, p.checkTimeout) + defer cancel() + + startedAt := time.Now() + if err := checkCtx.Err(); err != nil { + invocations := make([]invocation, len(p.checks)) + for index, check := range p.checks { + invocations[index] = canceledInvocation(index, check, err, time.Since(startedAt)) + } + return p.finalizeReadiness(ctx, invocations) + } + + invocations := make([]invocation, len(p.checks)) + completed := make([]bool, len(p.checks)) + results := make(chan invocation, len(p.checks)) + for index, check := range p.checks { + go func() { + results <- invoke(checkCtx, index, check) + }() + } + + for received := 0; received < len(p.checks); { + select { + case result := <-results: + if completed[result.index] { + continue + } + invocations[result.index] = result + completed[result.index] = true + received++ + case <-checkCtx.Done(): + for index, check := range p.checks { + if completed[index] { + continue + } + invocations[index] = canceledInvocation(index, check, checkCtx.Err(), time.Since(startedAt)) + completed[index] = true + received++ + } + } + } + + return p.finalizeReadiness(ctx, invocations) +} + +func (p *Probes) finalizeReadiness(ctx context.Context, invocations []invocation) Report { + report := Report{Status: StatusOK, Checks: make([]CheckResult, 0, len(invocations))} + for _, result := range invocations { + status := StatusOK + if result.outcome != OutcomeOK { + status = StatusFail + } + if result.critical && status == StatusFail { + report.Status = StatusFail + } + report.Checks = append(report.Checks, CheckResult{ + Name: result.name, + Status: status, + Critical: result.critical, + }) + observation := Observation{ + Name: result.name, + Critical: result.critical, + Outcome: result.outcome, + Duration: result.duration, + Err: result.err, + } + for _, observer := range p.observers { + observer.Observe(ctx, observation) + } + } + return report +} + +type invocation struct { + index int + name string + critical bool + outcome Outcome + duration time.Duration + err error +} + +func invoke(ctx context.Context, index int, check registration) (result invocation) { + startedAt := time.Now() + result = invocation{index: index, name: check.name, critical: check.critical} + defer func() { + result.duration = time.Since(startedAt) + if recovered := recover(); recovered != nil { + result.outcome = OutcomeError + result.err = fmt.Errorf( + "health: checker %q panicked: %v\n%s", + check.name, + recovered, + debug.Stack(), + ) + } + }() + + result.err = check.checker.Check(ctx) + result.outcome = classifyOutcome(result.err) + return result +} + +func canceledInvocation(index int, check registration, err error, duration time.Duration) invocation { + return invocation{ + index: index, + name: check.name, + critical: check.critical, + outcome: classifyOutcome(err), + duration: duration, + err: err, + } +} + +func classifyOutcome(err error) Outcome { + switch { + case err == nil: + return OutcomeOK + case errors.Is(err, context.DeadlineExceeded): + return OutcomeTimeout + default: + return OutcomeError + } +} diff --git a/health/health_test.go b/health/health_test.go new file mode 100644 index 0000000..13a36fa --- /dev/null +++ b/health/health_test.go @@ -0,0 +1,199 @@ +package health_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/health/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestReadinessRunsChecksImmediately(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).Return(nil) + probes, err := health.New(health.Critical("postgres", checker)) + require.NoError(t, err) + + require.Equal(t, health.StatusOK, probes.Readiness(context.Background()).Status) +} + +func TestReadinessRunsChecksConcurrentlyAndPreservesRegistrationOrder(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + first := mocks.NewMockChecker(ctrl) + second := mocks.NewMockChecker(ctrl) + started := make(chan string, 2) + release := make(chan struct{}) + first.EXPECT().Check(gomock.Any()).DoAndReturn(func(context.Context) error { + started <- "first" + <-release + return nil + }) + second.EXPECT().Check(gomock.Any()).DoAndReturn(func(context.Context) error { + started <- "second" + <-release + return errors.New("optional unavailable") + }) + + probes, err := health.New( + health.Critical("first", first), + health.NonCritical("second", second), + ) + require.NoError(t, err) + + reports := make(chan health.Report, 1) + go func() { + reports <- probes.Readiness(context.Background()) + }() + + seen := make([]string, 0, 2) + timer := time.NewTimer(time.Second) + defer timer.Stop() + for len(seen) < 2 { + select { + case name := <-started: + seen = append(seen, name) + case <-timer.C: + close(release) + require.FailNow(t, "checks did not start concurrently", "started: %v", seen) + } + } + close(release) + + report := <-reports + require.ElementsMatch(t, []string{"first", "second"}, seen) + require.Equal(t, health.StatusOK, report.Status) + require.Equal(t, []health.CheckResult{ + {Name: "first", Status: health.StatusOK, Critical: true}, + {Name: "second", Status: health.StatusFail, Critical: false}, + }, report.Checks) +} + +func TestReadinessAppliesOneTimeoutAndObservesIt(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).DoAndReturn(func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }) + observer := mocks.NewMockObserver(ctrl) + var observation health.Observation + observer.EXPECT().Observe(gomock.Any(), gomock.Any()).Do( + func(_ context.Context, got health.Observation) { observation = got }, + ) + + probes, err := health.New( + health.WithCheckTimeout(20*time.Millisecond), + health.WithObserver(observer), + health.Critical("postgres", checker), + ) + require.NoError(t, err) + + report := probes.Readiness(context.Background()) + require.Equal(t, health.StatusFail, report.Status) + require.Equal(t, health.OutcomeTimeout, observation.Outcome) + require.ErrorIs(t, observation.Err, context.DeadlineExceeded) + require.Equal(t, "postgres", observation.Name) + require.True(t, observation.Critical) +} + +func TestReadinessRecoversCheckerPanicAsObservedFailure(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).DoAndReturn(func(context.Context) error { + panic("broken checker") + }) + observer := mocks.NewMockObserver(ctrl) + var observation health.Observation + observer.EXPECT().Observe(gomock.Any(), gomock.Any()).Do( + func(_ context.Context, got health.Observation) { observation = got }, + ) + + probes, err := health.New( + health.WithObserver(observer), + health.Critical("postgres", checker), + ) + require.NoError(t, err) + + report := probes.Readiness(context.Background()) + require.Equal(t, health.StatusFail, report.Status) + require.Equal(t, health.OutcomeError, observation.Outcome) + require.Error(t, observation.Err) + require.Contains(t, observation.Err.Error(), "broken checker") + require.Contains(t, observation.Err.Error(), "goroutine") +} + +func TestReadinessTreatsParentCancellationAsError(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + observer := mocks.NewMockObserver(ctrl) + var observation health.Observation + observer.EXPECT().Observe(gomock.Any(), gomock.Any()).Do( + func(_ context.Context, got health.Observation) { observation = got }, + ) + + probes, err := health.New( + health.WithObserver(observer), + health.Critical("postgres", checker), + ) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + require.Equal(t, health.StatusFail, probes.Readiness(ctx).Status) + require.Equal(t, health.OutcomeError, observation.Outcome) + require.ErrorIs(t, observation.Err, context.Canceled) +} + +func TestNewValidatesOptions(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + + _, err := health.New(health.Critical(" ", checker)) + require.ErrorContains(t, err, "must not be blank") + + _, err = health.New(health.Critical("postgres", nil)) + require.ErrorContains(t, err, "must not be nil") + + _, err = health.New( + health.Critical("postgres", checker), + health.NonCritical("postgres", checker), + ) + require.ErrorContains(t, err, "duplicate") + + _, err = health.New(health.WithCheckTimeout(0)) + require.ErrorContains(t, err, "must be positive") + + _, err = health.New(health.WithObserver(nil)) + require.ErrorContains(t, err, "must not be nil") +} + +func TestNewUsesDefaultTimeoutAndNotifiesEveryObserver(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).Return(nil) + first := mocks.NewMockObserver(ctrl) + second := mocks.NewMockObserver(ctrl) + first.EXPECT().Observe(gomock.Any(), gomock.Any()) + second.EXPECT().Observe(gomock.Any(), gomock.Any()) + + probes, err := health.New( + health.Critical("postgres", checker), + health.WithObserver(first), + health.WithObserver(second), + ) + require.NoError(t, err) + require.Equal(t, time.Second, probes.CheckTimeout()) + require.Equal(t, health.StatusOK, probes.Readiness(context.Background()).Status) +} diff --git a/health/mocks/health.gen.go b/health/mocks/health.gen.go new file mode 100644 index 0000000..108fc89 --- /dev/null +++ b/health/mocks/health.gen.go @@ -0,0 +1,92 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/health (interfaces: Checker,Observer) +// +// Generated by this command: +// +// mockgen -destination mocks/health.gen.go -package mocks . Checker,Observer +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + health "github.com/devctllabs/go-libs/health" + gomock "go.uber.org/mock/gomock" +) + +// MockChecker is a mock of Checker interface. +type MockChecker struct { + ctrl *gomock.Controller + recorder *MockCheckerMockRecorder + isgomock struct{} +} + +// MockCheckerMockRecorder is the mock recorder for MockChecker. +type MockCheckerMockRecorder struct { + mock *MockChecker +} + +// NewMockChecker creates a new mock instance. +func NewMockChecker(ctrl *gomock.Controller) *MockChecker { + mock := &MockChecker{ctrl: ctrl} + mock.recorder = &MockCheckerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockChecker) EXPECT() *MockCheckerMockRecorder { + return m.recorder +} + +// Check mocks base method. +func (m *MockChecker) Check(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Check", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Check indicates an expected call of Check. +func (mr *MockCheckerMockRecorder) Check(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Check", reflect.TypeOf((*MockChecker)(nil).Check), ctx) +} + +// MockObserver is a mock of Observer interface. +type MockObserver struct { + ctrl *gomock.Controller + recorder *MockObserverMockRecorder + isgomock struct{} +} + +// MockObserverMockRecorder is the mock recorder for MockObserver. +type MockObserverMockRecorder struct { + mock *MockObserver +} + +// NewMockObserver creates a new mock instance. +func NewMockObserver(ctrl *gomock.Controller) *MockObserver { + mock := &MockObserver{ctrl: ctrl} + mock.recorder = &MockObserverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockObserver) EXPECT() *MockObserverMockRecorder { + return m.recorder +} + +// Observe mocks base method. +func (m *MockObserver) Observe(ctx context.Context, observation health.Observation) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Observe", ctx, observation) +} + +// Observe indicates an expected call of Observe. +func (mr *MockObserverMockRecorder) Observe(ctx, observation any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Observe", reflect.TypeOf((*MockObserver)(nil).Observe), ctx, observation) +} diff --git a/healthotel/doc.go b/healthotel/doc.go new file mode 100644 index 0000000..5f0bf91 --- /dev/null +++ b/healthotel/doc.go @@ -0,0 +1,5 @@ +// Package healthotel records health check observations with an explicitly supplied OpenTelemetry +// MeterProvider. It creates health.check.status, health.check.executions, and +// health.check.duration instruments once in New. Check names must be a bounded configured set; +// raw checker errors are never used as metric attributes. +package healthotel diff --git a/healthotel/example_test.go b/healthotel/example_test.go new file mode 100644 index 0000000..7338643 --- /dev/null +++ b/healthotel/example_test.go @@ -0,0 +1,12 @@ +package healthotel_test + +import ( + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/healthotel" + metricnoop "go.opentelemetry.io/otel/metric/noop" +) + +func ExampleNew() { + observer, _ := healthotel.New(metricnoop.NewMeterProvider()) + _, _ = health.New(health.WithObserver(observer)) +} diff --git a/healthotel/go.mod b/healthotel/go.mod new file mode 100644 index 0000000..c4d3cab --- /dev/null +++ b/healthotel/go.mod @@ -0,0 +1,25 @@ +module github.com/devctllabs/go-libs/healthotel + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/health v0.1.0 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk/metric v1.44.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/healthotel/go.sum b/healthotel/go.sum new file mode 100644 index 0000000..9e839dd --- /dev/null +++ b/healthotel/go.sum @@ -0,0 +1,46 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/healthotel/healthotel.go b/healthotel/healthotel.go new file mode 100644 index 0000000..9618cd5 --- /dev/null +++ b/healthotel/healthotel.go @@ -0,0 +1,88 @@ +package healthotel + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/health" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const instrumentationScope = "github.com/devctllabs/go-libs/healthotel" + +// Observer records health observations with a caller-owned OpenTelemetry provider. +type Observer struct { + status metric.Int64Gauge + executions metric.Int64Counter + duration metric.Float64Histogram +} + +var _ health.Observer = (*Observer)(nil) + +// New constructs all health metric instruments once from provider. +func New(provider metric.MeterProvider) (*Observer, error) { + if provider == nil { + return nil, errors.New("healthotel: MeterProvider must not be nil") + } + + meter := provider.Meter(instrumentationScope) + status, err := meter.Int64Gauge( + "health.check.status", + metric.WithDescription("Latest observed health check status, where 1 is healthy and 0 is unhealthy."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, fmt.Errorf("healthotel: create status gauge: %w", err) + } + executions, err := meter.Int64Counter( + "health.check.executions", + metric.WithDescription("Number of completed health check executions."), + metric.WithUnit("{check}"), + ) + if err != nil { + return nil, fmt.Errorf("healthotel: create executions counter: %w", err) + } + duration, err := meter.Float64Histogram( + "health.check.duration", + metric.WithDescription("Duration of health check executions."), + metric.WithUnit("s"), + ) + if err != nil { + return nil, fmt.Errorf("healthotel: create duration histogram: %w", err) + } + + return &Observer{status: status, executions: executions, duration: duration}, nil +} + +// Observe records one bounded-cardinality health observation. +func (o *Observer) Observe(ctx context.Context, observation health.Observation) { + outcome := boundedOutcome(observation.Outcome) + common := attribute.NewSet( + attribute.String("check.name", observation.Name), + attribute.Bool("check.critical", observation.Critical), + ) + status := int64(0) + if outcome == health.OutcomeOK { + status = 1 + } + o.status.Record(ctx, status, metric.WithAttributeSet(common)) + + result := attribute.NewSet( + attribute.String("check.name", observation.Name), + attribute.Bool("check.critical", observation.Critical), + attribute.String("result", string(outcome)), + ) + o.executions.Add(ctx, 1, metric.WithAttributeSet(result)) + o.duration.Record(ctx, observation.Duration.Seconds(), metric.WithAttributeSet(result)) +} + +func boundedOutcome(outcome health.Outcome) health.Outcome { + switch outcome { + case health.OutcomeOK, health.OutcomeError, health.OutcomeTimeout: + return outcome + default: + return health.OutcomeError + } +} diff --git a/healthotel/healthotel_test.go b/healthotel/healthotel_test.go new file mode 100644 index 0000000..9e44b42 --- /dev/null +++ b/healthotel/healthotel_test.go @@ -0,0 +1,117 @@ +package healthotel_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/healthotel" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func TestObserverRecordsStatusExecutionsAndDurationWithoutRawErrors(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + observer, err := healthotel.New(provider) + require.NoError(t, err) + + observer.Observe(context.Background(), health.Observation{ + Name: "postgres", + Critical: true, + Outcome: health.OutcomeError, + Duration: 125 * time.Millisecond, + Err: errors.New("postgres://secret@db.internal unavailable"), + }) + observer.Observe(context.Background(), health.Observation{ + Name: "postgres", + Critical: true, + Outcome: health.OutcomeOK, + Duration: 25 * time.Millisecond, + }) + + var data metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &data)) + metrics := metricsByName(data) + require.Contains(t, metrics, "health.check.status") + require.Contains(t, metrics, "health.check.executions") + require.Contains(t, metrics, "health.check.duration") + require.NotContains(t, fmt.Sprint(data), "secret") + + status, ok := metrics["health.check.status"].Data.(metricdata.Gauge[int64]) + require.True(t, ok) + require.Len(t, status.DataPoints, 1) + require.Equal(t, int64(1), status.DataPoints[0].Value) + name, ok := status.DataPoints[0].Attributes.Value(attribute.Key("check.name")) + require.True(t, ok) + require.Equal(t, "postgres", name.AsString()) + critical, ok := status.DataPoints[0].Attributes.Value(attribute.Key("check.critical")) + require.True(t, ok) + require.True(t, critical.AsBool()) + + executions, ok := metrics["health.check.executions"].Data.(metricdata.Sum[int64]) + require.True(t, ok) + require.Len(t, executions.DataPoints, 2) + var executionCount int64 + for _, point := range executions.DataPoints { + executionCount += point.Value + result, found := point.Attributes.Value(attribute.Key("result")) + require.True(t, found) + require.Contains(t, []string{"ok", "error"}, result.AsString()) + } + require.Equal(t, int64(2), executionCount) + + duration, ok := metrics["health.check.duration"].Data.(metricdata.Histogram[float64]) + require.True(t, ok) + require.Len(t, duration.DataPoints, 2) + var durationCount uint64 + for _, point := range duration.DataPoints { + durationCount += point.Count + } + require.Equal(t, uint64(2), durationCount) +} + +func TestNewRejectsNilMeterProvider(t *testing.T) { + t.Parallel() + _, err := healthotel.New(nil) + require.ErrorContains(t, err, "must not be nil") +} + +func TestObserverNormalizesUnknownOutcomeToError(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + observer, err := healthotel.New(provider) + require.NoError(t, err) + + observer.Observe(context.Background(), health.Observation{ + Name: "postgres", Outcome: health.Outcome("unbounded-value"), + }) + var data metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &data)) + require.NotContains(t, fmt.Sprint(data), "unbounded-value") + + executions := metricsByName(data)["health.check.executions"].Data.(metricdata.Sum[int64]) + require.Len(t, executions.DataPoints, 1) + result, ok := executions.DataPoints[0].Attributes.Value(attribute.Key("result")) + require.True(t, ok) + require.Equal(t, "error", result.AsString()) +} + +func metricsByName(data metricdata.ResourceMetrics) map[string]metricdata.Metrics { + metrics := make(map[string]metricdata.Metrics) + for _, scope := range data.ScopeMetrics { + for _, value := range scope.Metrics { + metrics[value.Name] = value + } + } + return metrics +} diff --git a/healthserver/api/openapi.yaml b/healthserver/api/openapi.yaml new file mode 100644 index 0000000..af7f610 --- /dev/null +++ b/healthserver/api/openapi.yaml @@ -0,0 +1,103 @@ +openapi: 3.1.0 +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +info: + title: Health probes + version: 1.0.0 +servers: + - url: http://localhost:8081 +security: [] +tags: + - name: health +paths: + /livez: + get: + operationId: getLivenessProbe + summary: Report whether the health server is live. + tags: [health] + responses: + '200': + $ref: '#/components/responses/ProbeSucceeded' + /readyz: + get: + operationId: getReadinessProbe + summary: Report whether the application can receive traffic. + tags: [health] + parameters: + - name: verbose + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + $ref: '#/components/responses/ProbeSucceeded' + '400': + $ref: '#/components/responses/BadRequest' + '503': + $ref: '#/components/responses/ProbeFailed' +components: + responses: + ProbeSucceeded: + description: The probe succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/ProbeResponse' + ProbeFailed: + description: The probe failed. + content: + application/json: + schema: + $ref: '#/components/schemas/ProbeResponse' + BadRequest: + description: A query parameter was malformed. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + schemas: + ProbeStatus: + type: string + enum: [ok, fail] + CheckResult: + type: object + additionalProperties: false + required: [name, status, critical] + properties: + name: + type: string + minLength: 1 + status: + $ref: '#/components/schemas/ProbeStatus' + critical: + type: boolean + ProbeResponse: + type: object + additionalProperties: false + required: [status] + properties: + status: + $ref: '#/components/schemas/ProbeStatus' + checks: + type: array + items: + $ref: '#/components/schemas/CheckResult' + ProblemDetails: + type: object + additionalProperties: false + required: [type, title, status, retryable] + properties: + type: + type: string + format: uri-reference + enum: [/problems/bad-request] + title: + type: string + minLength: 1 + status: + type: integer + enum: [400] + retryable: + type: boolean + enum: [false] diff --git a/healthserver/contract_test.go b/healthserver/contract_test.go new file mode 100644 index 0000000..fa505fb --- /dev/null +++ b/healthserver/contract_test.go @@ -0,0 +1,23 @@ +package healthserver + +import ( + "context" + "testing" + + "github.com/devctllabs/go-libs/healthserver/internal/generated" + "github.com/stretchr/testify/require" +) + +func TestEmbeddedOpenAPIContractIsValid(t *testing.T) { + t.Parallel() + document, err := generated.GetSpec() + require.NoError(t, err) + require.NoError(t, document.Validate(context.Background())) + + require.Nil(t, document.Paths.Find("/startupz")) + + liveness := document.Paths.Find("/livez") + require.NotNil(t, liveness) + require.NotNil(t, liveness.Get) + require.NotNil(t, liveness.Get.Responses.Value("200")) +} diff --git a/healthserver/doc.go b/healthserver/doc.go new file mode 100644 index 0000000..c8bf72c --- /dev/null +++ b/healthserver/doc.go @@ -0,0 +1,28 @@ +// Package healthserver exposes health probes through an OpenAPI-generated Echo 5 transport. +// +// Register mounts /livez and /readyz on an existing Echo instance. Existing global middleware +// still applies, so callers must ensure authentication does not block Kubernetes probes. NewServer +// creates a standalone server on :8081 with bounded HTTP timeouts. It does not install signal +// handlers or start goroutines; the application owns Serve or ListenAndServe and calls Shutdown +// explicitly. +// +// Probe clients should use HTTP status codes. Responses contain only {"status":"ok|fail"}; +// /readyz?verbose=true additionally returns safe component names, statuses, and criticality. +// Internal checker errors are never serialized. Before the management listener is available, a +// refused connection from /livez is the startup failure signal. +// +// A Kubernetes container can target the standalone server as follows: +// +// startupProbe: +// httpGet: {path: /livez, port: 8081} +// timeoutSeconds: 2 +// livenessProbe: +// httpGet: {path: /livez, port: 8081} +// timeoutSeconds: 2 +// readinessProbe: +// httpGet: {path: /readyz, port: 8081} +// timeoutSeconds: 2 +// +// Keep the management port out of public Services and Ingresses. Tune startup failure thresholds +// to the management listener's initialization budget. +package healthserver diff --git a/healthserver/example_test.go b/healthserver/example_test.go new file mode 100644 index 0000000..32632d2 --- /dev/null +++ b/healthserver/example_test.go @@ -0,0 +1,25 @@ +package healthserver_test + +import ( + "log" + "os" + + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/healthserver" + "github.com/labstack/echo/v5" +) + +func ExampleRegister() { + probes, _ := health.New() + e := echo.New() + _ = healthserver.Register(e, probes) +} + +func ExampleNewServer() { + logger := log.New(os.Stdout, "", 0) + probes, _ := health.New() + server, _ := healthserver.NewServer(probes) + logger.Println(server.Address()) + + // Output: :8081 +} diff --git a/healthserver/go.mod b/healthserver/go.mod new file mode 100644 index 0000000..1318541 --- /dev/null +++ b/healthserver/go.mod @@ -0,0 +1,37 @@ +module github.com/devctllabs/go-libs/healthserver + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/health v0.1.0 + github.com/getkin/kin-openapi v0.142.0 + github.com/labstack/echo/v5 v5.1.1 + github.com/oapi-codegen/runtime v1.6.0 + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/healthserver/go.sum b/healthserver/go.sum new file mode 100644 index 0000000..87af779 --- /dev/null +++ b/healthserver/go.sum @@ -0,0 +1,188 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= +github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/healthserver/healthserver.go b/healthserver/healthserver.go new file mode 100644 index 0000000..272876c --- /dev/null +++ b/healthserver/healthserver.go @@ -0,0 +1,114 @@ +package healthserver + +import ( + "context" + "errors" + + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/healthserver/internal/generated" + "github.com/labstack/echo/v5" +) + +// Register adds the fixed health probe routes to e. +func Register(e *echo.Echo, probes *health.Probes) error { + if e == nil { + return errors.New("healthserver: Echo must not be nil") + } + if probes == nil { + return errors.New("healthserver: Probes must not be nil") + } + + handler := generated.NewStrictHandler(&strictServer{probes: probes}, nil) + probeMiddleware := []echo.MiddlewareFunc{probeErrors, noStore} + generated.RegisterHandlersWithOptions(e, handler, generated.RegisterHandlersOptions{ + OperationMiddlewares: map[string][]echo.MiddlewareFunc{ + "getLivenessProbe": probeMiddleware, + "getReadinessProbe": probeMiddleware, + }, + }) + return nil +} + +type strictServer struct { + probes *health.Probes +} + +func (s *strictServer) GetLivenessProbe( + _ context.Context, + _ generated.GetLivenessProbeRequestObject, +) (generated.GetLivenessProbeResponseObject, error) { + return generated.GetLivenessProbe200JSONResponse{ + ProbeSucceededJSONResponse: probeResponse(s.probes.Liveness()), + }, nil +} + +func (s *strictServer) GetReadinessProbe( + ctx context.Context, + request generated.GetReadinessProbeRequestObject, +) (generated.GetReadinessProbeResponseObject, error) { + report := s.probes.Readiness(ctx) + response := readinessResponse(report, request.Params.Verbose != nil && *request.Params.Verbose) + if report.Status == health.StatusOK { + return generated.GetReadinessProbe200JSONResponse{ + ProbeSucceededJSONResponse: generated.ProbeSucceededJSONResponse(response), + }, nil + } + return generated.GetReadinessProbe503JSONResponse{ + ProbeFailedJSONResponse: generated.ProbeFailedJSONResponse(response), + }, nil +} + +func probeResponse(report health.Report) generated.ProbeSucceededJSONResponse { + return generated.ProbeSucceededJSONResponse{ + Status: generated.ProbeStatus(report.Status), + } +} + +func readinessResponse(report health.Report, verbose bool) generated.ProbeResponse { + response := generated.ProbeResponse{Status: generated.ProbeStatus(report.Status)} + if !verbose { + return response + } + + checks := make([]generated.CheckResult, 0, len(report.Checks)) + for _, check := range report.Checks { + checks = append(checks, generated.CheckResult{ + Name: check.Name, + Status: generated.ProbeStatus(check.Status), + Critical: check.Critical, + }) + } + response.Checks = &checks + return response +} + +func noStore(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + c.Response().Header().Set("Cache-Control", "no-store") + return next(c) + } +} + +func probeErrors(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + err := next(c) + if err == nil { + return nil + } + + var httpError *echo.HTTPError + if !errors.As(err, &httpError) || httpError.Code != 400 { + return err + } + + response := generated.GetReadinessProbe400ApplicationProblemPlusJSONResponse{ + BadRequestApplicationProblemPlusJSONResponse: generated.BadRequestApplicationProblemPlusJSONResponse{ + Type: generated.ProblemsbadRequest, + Title: "Bad Request", + Status: generated.N400, + Retryable: generated.False, + }, + } + return response.VisitGetReadinessProbeResponse(c.Response()) + } +} diff --git a/healthserver/healthserver_test.go b/healthserver/healthserver_test.go new file mode 100644 index 0000000..5202db9 --- /dev/null +++ b/healthserver/healthserver_test.go @@ -0,0 +1,196 @@ +package healthserver_test + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/health/mocks" + "github.com/devctllabs/go-libs/healthserver" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestRegisterServesLiveness(t *testing.T) { + t.Parallel() + probes, err := health.New() + require.NoError(t, err) + + e := echo.New() + require.NoError(t, healthserver.Register(e, probes)) + + request := httptest.NewRequest(http.MethodGet, "/livez", nil) + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "application/json", recorder.Header().Get("Content-Type")) + require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + require.JSONEq(t, `{"status":"ok"}`, recorder.Body.String()) +} + +func TestRegisterDoesNotExposeStartupProbe(t *testing.T) { + t.Parallel() + probes, err := health.New() + require.NoError(t, err) + e := echo.New() + require.NoError(t, healthserver.Register(e, probes)) + + recorder := serve(t, e, "/startupz") + require.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestReadinessRunsChecksButOnlyVerboseReturnsThem(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).Return(nil).Times(2) + + probes, err := health.New(health.Critical("postgres", checker)) + require.NoError(t, err) + e := echo.New() + require.NoError(t, healthserver.Register(e, probes)) + + brief := serve(t, e, "/readyz") + require.Equal(t, http.StatusOK, brief.Code) + require.JSONEq(t, `{"status":"ok"}`, brief.Body.String()) + + verbose := serve(t, e, "/readyz?verbose=true") + require.Equal(t, http.StatusOK, verbose.Code) + require.JSONEq(t, `{ + "status":"ok", + "checks":[{"name":"postgres","status":"ok","critical":true}] + }`, verbose.Body.String()) +} + +func TestReadinessVerboseFalseReturnsBriefResponse(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).Return(nil) + probes, err := health.New(health.Critical("postgres", checker)) + require.NoError(t, err) + e := echo.New() + require.NoError(t, healthserver.Register(e, probes)) + + recorder := serve(t, e, "/readyz?verbose=false") + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"status":"ok"}`, recorder.Body.String()) +} + +func TestReadinessCriticalFailureReturnsUnavailableWithoutErrorDetails(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).Return(errors.New("postgres://secret@db.internal unavailable")) + + probes, err := health.New(health.Critical("postgres", checker)) + require.NoError(t, err) + e := echo.New() + require.NoError(t, healthserver.Register(e, probes)) + + recorder := serve(t, e, "/readyz?verbose=true") + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.JSONEq(t, `{ + "status":"fail", + "checks":[{"name":"postgres","status":"fail","critical":true}] + }`, recorder.Body.String()) + require.NotContains(t, recorder.Body.String(), "secret") +} + +func TestReadinessNonCriticalFailureKeepsOverallStatusOK(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + checker := mocks.NewMockChecker(ctrl) + checker.EXPECT().Check(gomock.Any()).Return(errors.New("cache unavailable")) + + probes, err := health.New(health.NonCritical("cache", checker)) + require.NoError(t, err) + e := echo.New() + require.NoError(t, healthserver.Register(e, probes)) + + recorder := serve(t, e, "/readyz?verbose=true") + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{ + "status":"ok", + "checks":[{"name":"cache","status":"fail","critical":false}] + }`, recorder.Body.String()) +} + +func TestReadinessRejectsMalformedVerboseAsProblemDetails(t *testing.T) { + t.Parallel() + probes, err := health.New() + require.NoError(t, err) + e := echo.New() + require.NoError(t, healthserver.Register(e, probes)) + + for _, target := range []string{"/readyz?verbose=invalid", "/readyz?verbose"} { + t.Run(target, func(t *testing.T) { + t.Parallel() + recorder := serve(t, e, target) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, "application/problem+json", recorder.Header().Get("Content-Type")) + require.JSONEq(t, `{ + "type":"/problems/bad-request", + "title":"Bad Request", + "status":400, + "retryable":false + }`, recorder.Body.String()) + }) + } +} + +func TestStandaloneServerServesAndShutsDown(t *testing.T) { + t.Parallel() + probes, err := health.New() + require.NoError(t, err) + server, err := healthserver.NewServer(probes) + require.NoError(t, err) + require.Equal(t, ":8081", server.Address()) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + runErr := make(chan error, 1) + go func() { + runErr <- server.Serve(listener) + }() + + response, err := http.Get("http://" + listener.Addr().String() + "/livez") + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.JSONEq(t, `{"status":"ok"}`, string(body)) + + require.NoError(t, server.Shutdown(context.Background())) + require.NoError(t, <-runErr) +} + +func TestStandaloneServerValidatesInputs(t *testing.T) { + t.Parallel() + _, err := healthserver.NewServer(nil) + require.ErrorContains(t, err, "must not be nil") + + probes, err := health.New() + require.NoError(t, err) + _, err = healthserver.NewServer(probes, healthserver.WithAddress(" ")) + require.ErrorContains(t, err, "must not be blank") + + require.Error(t, healthserver.Register(nil, probes)) + require.Error(t, healthserver.Register(echo.New(), nil)) +} + +func serve(t *testing.T, e *echo.Echo, target string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodGet, target, nil) + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, request) + return recorder +} diff --git a/healthserver/internal/generated/generate.go b/healthserver/internal/generated/generate.go new file mode 100644 index 0000000..0c627db --- /dev/null +++ b/healthserver/internal/generated/generate.go @@ -0,0 +1,4 @@ +// Package generated contains the generated Echo 5 health server contract. +package generated + +//go:generate go tool oapi-codegen -config server.yaml -o server.gen.go ../../api/openapi.yaml diff --git a/healthserver/internal/generated/server.gen.go b/healthserver/internal/generated/server.gen.go new file mode 100644 index 0000000..c33f6c1 --- /dev/null +++ b/healthserver/internal/generated/server.gen.go @@ -0,0 +1,485 @@ +// Package generated provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package generated + +import ( + "bytes" + "compress/flate" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/labstack/echo/v5" + "github.com/oapi-codegen/runtime" +) + +// Defines values for ProbeStatus. +const ( + Fail ProbeStatus = "fail" + Ok ProbeStatus = "ok" +) + +// Valid indicates whether the value is a known member of the ProbeStatus enum. +func (e ProbeStatus) Valid() bool { + switch e { + case Fail: + return true + case Ok: + return true + default: + return false + } +} + +// Defines values for ProblemDetailsRetryable. +const ( + False ProblemDetailsRetryable = false +) + +// Valid indicates whether the value is a known member of the ProblemDetailsRetryable enum. +func (e ProblemDetailsRetryable) Valid() bool { + switch e { + case False: + return true + default: + return false + } +} + +// Defines values for ProblemDetailsStatus. +const ( + N400 ProblemDetailsStatus = 400 +) + +// Valid indicates whether the value is a known member of the ProblemDetailsStatus enum. +func (e ProblemDetailsStatus) Valid() bool { + switch e { + case N400: + return true + default: + return false + } +} + +// Defines values for ProblemDetailsType. +const ( + ProblemsbadRequest ProblemDetailsType = "/problems/bad-request" +) + +// Valid indicates whether the value is a known member of the ProblemDetailsType enum. +func (e ProblemDetailsType) Valid() bool { + switch e { + case ProblemsbadRequest: + return true + default: + return false + } +} + +// CheckResult defines model for CheckResult. +type CheckResult struct { + Critical bool `json:"critical"` + Name string `json:"name"` + Status ProbeStatus `json:"status"` +} + +// ProbeResponse defines model for ProbeResponse. +type ProbeResponse struct { + Checks *[]CheckResult `json:"checks,omitempty"` + Status ProbeStatus `json:"status"` +} + +// ProbeStatus defines model for ProbeStatus. +type ProbeStatus string + +// ProblemDetails defines model for ProblemDetails. +type ProblemDetails struct { + Retryable ProblemDetailsRetryable `json:"retryable"` + Status ProblemDetailsStatus `json:"status"` + Title string `json:"title"` + Type ProblemDetailsType `json:"type"` +} + +// ProblemDetailsRetryable defines model for ProblemDetails.Retryable. +type ProblemDetailsRetryable bool + +// ProblemDetailsStatus defines model for ProblemDetails.Status. +type ProblemDetailsStatus int + +// ProblemDetailsType defines model for ProblemDetails.Type. +type ProblemDetailsType string + +// BadRequest defines model for BadRequest. +type BadRequest = ProblemDetails + +// ProbeFailed defines model for ProbeFailed. +type ProbeFailed = ProbeResponse + +// ProbeSucceeded defines model for ProbeSucceeded. +type ProbeSucceeded = ProbeResponse + +// GetReadinessProbeParams defines parameters for GetReadinessProbe. +type GetReadinessProbeParams struct { + Verbose *bool `form:"verbose,omitempty" json:"verbose,omitempty"` +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // GetLivenessProbe Report whether the health server is live. + // (GET /livez) + GetLivenessProbe(ctx *echo.Context) error + // GetReadinessProbe Report whether the application can receive traffic. + // (GET /readyz) + GetReadinessProbe(ctx *echo.Context, params GetReadinessProbeParams) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// GetLivenessProbe converts echo context to params. +func (w *ServerInterfaceWrapper) GetLivenessProbe(ctx *echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetLivenessProbe(ctx) + return err +} + +// GetReadinessProbe converts echo context to params. +func (w *ServerInterfaceWrapper) GetReadinessProbe(ctx *echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params GetReadinessProbeParams + // ------------- Optional query parameter "verbose" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "verbose", ctx.QueryParams(), ¶ms.Verbose, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter verbose: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetReadinessProbe(ctx, params) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo +} + +// RegisterHandlersOptions configures RegisterHandlersWithOptions. +type RegisterHandlersOptions struct { + // BaseURL is prepended to every registered path so the API can be served + // under a prefix. + BaseURL string + // OperationMiddlewares lets the caller attach per-operation middleware at + // registration time. The map key is the OpenAPI `operationId` value as it + // appears in the spec (the raw, un-normalized form). Operations that have + // no entry are registered with no extra middleware. A nil map disables + // per-operation middleware entirely. + OperationMiddlewares map[string][]echo.MiddlewareFunc +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{}) +} + +// RegisterHandlersWithBaseURL registers handlers and prepends BaseURL to the +// paths so the API can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{BaseURL: baseURL}) +} + +// RegisterHandlersWithOptions registers handlers using the supplied options, +// including any per-operation middleware. +func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options RegisterHandlersOptions) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(options.BaseURL+"/livez", wrapper.GetLivenessProbe, options.OperationMiddlewares["getLivenessProbe"]...) + router.GET(options.BaseURL+"/readyz", wrapper.GetReadinessProbe, options.OperationMiddlewares["getReadinessProbe"]...) + +} + +type BadRequestApplicationProblemPlusJSONResponse ProblemDetails + +type ProbeFailedJSONResponse ProbeResponse + +type ProbeSucceededJSONResponse ProbeResponse + +type GetLivenessProbeRequestObject struct { +} + +type GetLivenessProbeResponseObject interface { + VisitGetLivenessProbeResponse(w http.ResponseWriter) error +} + +type GetLivenessProbe200JSONResponse struct{ ProbeSucceededJSONResponse } + +func (response GetLivenessProbe200JSONResponse) VisitGetLivenessProbeResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetReadinessProbeRequestObject struct { + Params GetReadinessProbeParams +} + +type GetReadinessProbeResponseObject interface { + VisitGetReadinessProbeResponse(w http.ResponseWriter) error +} + +type GetReadinessProbe200JSONResponse struct{ ProbeSucceededJSONResponse } + +func (response GetReadinessProbe200JSONResponse) VisitGetReadinessProbeResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetReadinessProbe400ApplicationProblemPlusJSONResponse struct { + BadRequestApplicationProblemPlusJSONResponse +} + +func (response GetReadinessProbe400ApplicationProblemPlusJSONResponse) VisitGetReadinessProbeResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type GetReadinessProbe503JSONResponse struct{ ProbeFailedJSONResponse } + +func (response GetReadinessProbe503JSONResponse) VisitGetReadinessProbeResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(503) + _, err := buf.WriteTo(w) + return err +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // GetLivenessProbe Report whether the health server is live. + // (GET /livez) + GetLivenessProbe(ctx context.Context, request GetLivenessProbeRequestObject) (GetLivenessProbeResponseObject, error) + // GetReadinessProbe Report whether the application can receive traffic. + // (GET /readyz) + GetReadinessProbe(ctx context.Context, request GetReadinessProbeRequestObject) (GetReadinessProbeResponseObject, error) +} + +type StrictHandlerFunc func(ctx *echo.Context, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetLivenessProbe operation middleware +func (sh *strictHandler) GetLivenessProbe(ctx *echo.Context) error { + var request GetLivenessProbeRequestObject + + handler := func(ctx *echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetLivenessProbe(ctx.Request().Context(), request.(GetLivenessProbeRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetLivenessProbe") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetLivenessProbeResponseObject); ok { + return validResponse.VisitGetLivenessProbeResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetReadinessProbe operation middleware +func (sh *strictHandler) GetReadinessProbe(ctx *echo.Context, params GetReadinessProbeParams) error { + var request GetReadinessProbeRequestObject + + request.Params = params + + handler := func(ctx *echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetReadinessProbe(ctx.Request().Context(), request.(GetReadinessProbeRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetReadinessProbe") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetReadinessProbeResponseObject); ok { + return validResponse.VisitGetReadinessProbeResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// Base64 encoded, compressed with deflate, json marshaled OpenAPI spec. +// Stored as a slice of fixed-width chunks rather than one concatenated +// const string: with thousands of chunks the chained `+` fold is several +// times slower for the Go compiler than parsing a slice literal. +var swaggerSpec = []string{ + "xFVNj9s2EP0rwrS3aiV5kwKBbm2DfgA5BHZvhQ9jamQxoUjtkHLgGvrvxVCyJLj58AIL5EaJwzfvvRkO", + "L6Bc2zlLNngoL8DkO2c9xY9fsdrSU08+yJdyNpCNS+w6oxUG7WzesTsYan/64J2VPa8aalFWPzLVUMIP", + "+ZIiH3d9/n489ZYCauNhGIYUKvKKdSeoUMIvyVNPfE46ZGwpECef0CctmtpxS1UGQwqCQr+jNlR9heHz", + "mdF2suFzxP5uKBHRlNQx88Jk1ytFVH0fMv6aPAMJmxAkwW8NqY9b8r0Z2VSVltNo3rPriIOWatdoPKXQ", + "rX5dQLEOWqGRdTh3BCUcnDOEVjRbbEl2Wm3fkT2GBspNeo3zgbU9SpgPGHp/l9LdGCr8mZ56zWLlP2Oi", + "GShdaO3ndO7wgVSYKzFb9ky94lRc6UDtNzmvjR1mKsiM55cRPiF8UeZuzkC2b+WE+wgpSF+uDi2luLl1", + "zzOHKfAZD4ZW+WLgkmnVHP6W2uuiWAK1DXQkjq7pYO5po/HHovQ6eHx+wOqBpzm1T0HmAwYooWf9wFQT", + "k1XSPzeQN17H3SudVbMtsv9fBsHQtnbxfow64E9CE5rxUsr5E7Ef7+omK7JCpMgM2MXyv9VoBKmEJoTO", + "l3mcDw9jb2SOj3nFWIf8sXgsHjaPU9NACq4ji52GEl5lm6yAFDoMTfQ7N/pE/8rqSPHCSxHj8PmrghL+", + "oPBOn8iS97GHosTV0H8sii+17ByX34y7OHD6tkU+Qwlb6hyH5FNDoSFOQkNJM5riiU/EifaJcMzEbjx6", + "cX8MgL0g5UxYnb+qYEtY6bWE+ZUQtAto8Tu+H3CdU1KIg/OxtvPsrajGOBanjr/t42H/Eu6k8PqeY6vn", + "dkjh5+LVnZmmB/CbRVg9Q4lCmzAp0idKAmNda/X5cghorNpobM9matYyz41TaBrnQ/mmeLMBMWsCuFxN", + "n4CG/fBfAAAA//8=", +} + +// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, +// after base64-decoding and flate-decompressing the embedded blob. +func decodeSpec() ([]byte, error) { + encoded := strings.Join(swaggerSpec, "") + compressed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr := flate.NewReader(bytes.NewReader(compressed)) + var buf bytes.Buffer + if _, err := buf.ReadFrom(zr); err != nil { + return nil, fmt.Errorf("read flate: %w", err) + } + if err := zr.Close(); err != nil { + return nil, fmt.Errorf("close flate reader: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cache of the decoded OpenAPI spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSpec returns the OpenAPI specification corresponding to the generated +// code in this file. External references in the spec are resolved through +// PathToRawSpec; externally-referenced files must be embedded in their +// corresponding Go packages (via the import-mapping feature). URL-based +// external refs are not supported. +func GetSpec() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} + +// GetSpecJSON returns the raw JSON bytes of the embedded OpenAPI +// specification: decompressed but not unmarshaled. External references +// are not resolved here; the bytes are the spec exactly as embedded by +// codegen. The result is cached at package init time, so repeated calls +// are cheap. +func GetSpecJSON() ([]byte, error) { + return rawSpec() +} + +// GetSwagger returns the OpenAPI specification corresponding to the +// generated code in this file. +// +// Deprecated: GetSwagger predates kin-openapi renaming openapi3.Swagger +// to openapi3.T. Use [GetSpec] instead. This wrapper is retained for +// backwards compatibility. +func GetSwagger() (*openapi3.T, error) { + return GetSpec() +} diff --git a/healthserver/internal/generated/server.yaml b/healthserver/internal/generated/server.yaml new file mode 100644 index 0000000..8d2f8f5 --- /dev/null +++ b/healthserver/internal/generated/server.yaml @@ -0,0 +1,6 @@ +package: generated +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/healthserver/server.go b/healthserver/server.go new file mode 100644 index 0000000..aa6ea21 --- /dev/null +++ b/healthserver/server.go @@ -0,0 +1,104 @@ +package healthserver + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "time" + + "github.com/devctllabs/go-libs/health" + "github.com/labstack/echo/v5" +) + +type serverConfig struct { + address string +} + +// Option configures a standalone Server during construction. +type Option interface { + apply(*serverConfig) error +} + +type serverOptionFunc func(*serverConfig) error + +func (f serverOptionFunc) apply(cfg *serverConfig) error { + return f(cfg) +} + +// WithAddress replaces the default standalone address :8081. +func WithAddress(address string) Option { + return serverOptionFunc(func(cfg *serverConfig) error { + if strings.TrimSpace(address) == "" { + return errors.New("healthserver: address must not be blank") + } + cfg.address = address + return nil + }) +} + +// Server owns the standalone health HTTP server lifecycle. +type Server struct { + http *http.Server +} + +// NewServer creates a standalone Echo health server without starting it. +func NewServer(probes *health.Probes, options ...Option) (*Server, error) { + if probes == nil { + return nil, errors.New("healthserver: Probes must not be nil") + } + + cfg := serverConfig{address: ":8081"} + for _, option := range options { + if option == nil { + continue + } + if err := option.apply(&cfg); err != nil { + return nil, err + } + } + + e := echo.New() + if err := Register(e, probes); err != nil { + return nil, err + } + return &Server{http: &http.Server{ + Addr: cfg.address, + Handler: e, + ReadHeaderTimeout: 2 * time.Second, + ReadTimeout: 2 * time.Second, + WriteTimeout: probes.CheckTimeout() + time.Second, + IdleTimeout: 30 * time.Second, + }}, nil +} + +// Address returns the configured listen address. +func (s *Server) Address() string { + return s.http.Addr +} + +// ListenAndServe starts the configured TCP listener and blocks until shutdown or failure. +func (s *Server) ListenAndServe() error { + return normalizeServerError(s.http.ListenAndServe()) +} + +// Serve accepts HTTP connections from listener and blocks until shutdown or failure. +func (s *Server) Serve(listener net.Listener) error { + if listener == nil { + return errors.New("healthserver: listener must not be nil") + } + return normalizeServerError(s.http.Serve(listener)) +} + +// Shutdown gracefully stops the standalone server using ctx as its deadline. +func (s *Server) Shutdown(ctx context.Context) error { + return s.http.Shutdown(ctx) +} + +func normalizeServerError(err error) error { + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} diff --git a/healthzap/doc.go b/healthzap/doc.go new file mode 100644 index 0000000..69857a0 --- /dev/null +++ b/healthzap/doc.go @@ -0,0 +1,5 @@ +// Package healthzap logs health check failures and recoveries with a caller-owned zap logger. +// Every critical failure is logged at error, every non-critical failure at warn, and the first +// success after failures at info. Ordinary successes are silent. Callers own logger construction, +// redaction, output, and lifecycle. +package healthzap diff --git a/healthzap/example_test.go b/healthzap/example_test.go new file mode 100644 index 0000000..e0dc558 --- /dev/null +++ b/healthzap/example_test.go @@ -0,0 +1,12 @@ +package healthzap_test + +import ( + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/healthzap" + "go.uber.org/zap" +) + +func ExampleNew() { + observer, _ := healthzap.New(zap.NewNop()) + _, _ = health.New(health.WithObserver(observer)) +} diff --git a/healthzap/go.mod b/healthzap/go.mod new file mode 100644 index 0000000..35e63ca --- /dev/null +++ b/healthzap/go.mod @@ -0,0 +1,16 @@ +module github.com/devctllabs/go-libs/healthzap + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/health v0.1.0 + github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.28.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/healthzap/go.sum b/healthzap/go.sum new file mode 100644 index 0000000..0c17751 --- /dev/null +++ b/healthzap/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/healthzap/healthzap.go b/healthzap/healthzap.go new file mode 100644 index 0000000..76a35f2 --- /dev/null +++ b/healthzap/healthzap.go @@ -0,0 +1,67 @@ +package healthzap + +import ( + "context" + "errors" + "sync" + + "github.com/devctllabs/go-libs/health" + "go.uber.org/zap" +) + +// Observer writes every failed health observation and one recovery after failures. +type Observer struct { + logger *zap.Logger + + mu sync.Mutex + failed map[string]bool +} + +var _ health.Observer = (*Observer)(nil) + +// New creates an instance-owned logging observer. +func New(logger *zap.Logger) (*Observer, error) { + if logger == nil { + return nil, errors.New("healthzap: logger must not be nil") + } + return &Observer{logger: logger, failed: make(map[string]bool)}, nil +} + +// Observe logs failures at a level selected by criticality and logs recovery once. +func (o *Observer) Observe(_ context.Context, observation health.Observation) { + if observation.Outcome != health.OutcomeOK { + o.mu.Lock() + o.failed[observation.Name] = true + o.mu.Unlock() + + fields := observationFields(observation) + if observation.Err != nil { + fields = append(fields, zap.Error(observation.Err)) + } + if observation.Critical { + o.logger.Error("health check failed", fields...) + return + } + o.logger.Warn("health check failed", fields...) + return + } + + o.mu.Lock() + wasFailed := o.failed[observation.Name] + if wasFailed { + o.failed[observation.Name] = false + } + o.mu.Unlock() + if wasFailed { + o.logger.Info("health check recovered", observationFields(observation)...) + } +} + +func observationFields(observation health.Observation) []zap.Field { + return []zap.Field{ + zap.String("check.name", observation.Name), + zap.Bool("check.critical", observation.Critical), + zap.String("check.outcome", string(observation.Outcome)), + zap.Duration("check.duration", observation.Duration), + } +} diff --git a/healthzap/healthzap_test.go b/healthzap/healthzap_test.go new file mode 100644 index 0000000..35e73ef --- /dev/null +++ b/healthzap/healthzap_test.go @@ -0,0 +1,102 @@ +package healthzap_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/devctllabs/go-libs/health" + "github.com/devctllabs/go-libs/healthzap" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestObserverLogsEveryFailureAndOneRecovery(t *testing.T) { + t.Parallel() + core, observed := observer.New(zapcore.DebugLevel) + logger := zap.New(core) + logObserver, err := healthzap.New(logger) + require.NoError(t, err) + + criticalFailure := health.Observation{ + Name: "postgres", + Critical: true, + Outcome: health.OutcomeTimeout, + Duration: time.Second, + Err: errors.New("database unavailable"), + } + logObserver.Observe(context.Background(), criticalFailure) + logObserver.Observe(context.Background(), criticalFailure) + logObserver.Observe(context.Background(), health.Observation{ + Name: "postgres", Outcome: health.OutcomeOK, Duration: 10 * time.Millisecond, + }) + logObserver.Observe(context.Background(), health.Observation{ + Name: "postgres", Outcome: health.OutcomeOK, Duration: 10 * time.Millisecond, + }) + logObserver.Observe(context.Background(), health.Observation{ + Name: "cache", Critical: false, Outcome: health.OutcomeError, + Duration: 25 * time.Millisecond, Err: errors.New("cache unavailable"), + }) + + entries := observed.All() + require.Len(t, entries, 4) + require.Equal(t, zapcore.ErrorLevel, entries[0].Level) + require.Equal(t, zapcore.ErrorLevel, entries[1].Level) + require.Equal(t, zapcore.InfoLevel, entries[2].Level) + require.Equal(t, "health check recovered", entries[2].Message) + require.Equal(t, zapcore.WarnLevel, entries[3].Level) + require.Equal(t, "health check failed", entries[3].Message) + require.Equal(t, "cache", entries[3].ContextMap()["check.name"]) + require.Equal(t, false, entries[3].ContextMap()["check.critical"]) + require.Equal(t, "error", entries[3].ContextMap()["check.outcome"]) + require.Equal(t, "cache unavailable", entries[3].ContextMap()["error"]) +} + +func TestObserverDoesNotLogInitialSuccess(t *testing.T) { + t.Parallel() + core, observed := observer.New(zapcore.DebugLevel) + logObserver, err := healthzap.New(zap.New(core)) + require.NoError(t, err) + + logObserver.Observe(context.Background(), health.Observation{ + Name: "postgres", Critical: true, Outcome: health.OutcomeOK, + }) + require.Empty(t, observed.All()) +} + +func TestNewRejectsNilLogger(t *testing.T) { + t.Parallel() + _, err := healthzap.New(nil) + require.ErrorContains(t, err, "must not be nil") +} + +func TestObserverIsSafeForConcurrentFailures(t *testing.T) { + t.Parallel() + core, observed := observer.New(zapcore.DebugLevel) + logObserver, err := healthzap.New(zap.New(core)) + require.NoError(t, err) + failure := health.Observation{ + Name: "postgres", Critical: true, Outcome: health.OutcomeError, + Err: errors.New("database unavailable"), + } + + var wait sync.WaitGroup + for range 100 { + wait.Add(1) + go func() { + defer wait.Done() + logObserver.Observe(context.Background(), failure) + }() + } + wait.Wait() + logObserver.Observe(context.Background(), health.Observation{ + Name: "postgres", Critical: true, Outcome: health.OutcomeOK, + }) + + require.Len(t, observed.FilterLevelExact(zapcore.ErrorLevel).All(), 100) + require.Len(t, observed.FilterLevelExact(zapcore.InfoLevel).All(), 1) +} diff --git a/lifecycle/doc.go b/lifecycle/doc.go new file mode 100644 index 0000000..3ceb16b --- /dev/null +++ b/lifecycle/doc.go @@ -0,0 +1,7 @@ +// Package lifecycle coordinates long-running application tasks and one common graceful shutdown. +// +// The caller owns signal handling, dependency construction, and configuration. Tasks must either +// honor their context or be stopped by Config.Shutdown. Run calls shutdown before waiting for all +// tasks, so servers whose Serve method returns only after Shutdown are supported without extra +// goroutines in application code. +package lifecycle diff --git a/lifecycle/example_test.go b/lifecycle/example_test.go new file mode 100644 index 0000000..e33f5d7 --- /dev/null +++ b/lifecycle/example_test.go @@ -0,0 +1,36 @@ +package lifecycle_test + +import ( + "context" + "log" + "os" + "time" + + "github.com/devctllabs/go-libs/lifecycle" +) + +func ExampleRun() { + ctx, cancel := context.WithCancel(context.Background()) + started := make(chan struct{}) + go func() { + <-started + cancel() + }() + + err := lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: 5 * time.Second, + Shutdown: func(context.Context) error { return nil }, + Tasks: []lifecycle.Task{{ + Name: "api", + Run: func(ctx context.Context) error { + close(started) + <-ctx.Done() + return ctx.Err() + }, + }}, + }) + logger := log.New(os.Stdout, "", 0) + logger.Println(err) + + // Output: +} diff --git a/lifecycle/go.mod b/lifecycle/go.mod new file mode 100644 index 0000000..0f47bf4 --- /dev/null +++ b/lifecycle/go.mod @@ -0,0 +1,14 @@ +module github.com/devctllabs/go-libs/lifecycle + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.22.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/lifecycle/go.sum b/lifecycle/go.sum new file mode 100644 index 0000000..5f1ff8c --- /dev/null +++ b/lifecycle/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lifecycle/lifecycle.go b/lifecycle/lifecycle.go new file mode 100644 index 0000000..778d1f5 --- /dev/null +++ b/lifecycle/lifecycle.go @@ -0,0 +1,108 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/sync/errgroup" +) + +// ErrTaskStopped reports that a long-running task returned nil before cancellation. +var ErrTaskStopped = errors.New("lifecycle: task stopped unexpectedly") + +// Task is one named long-running workload. +type Task struct { + Name string + Run func(ctx context.Context) error +} + +// Config describes the workloads and common application shutdown owned by Run. +type Config struct { + ShutdownTimeout time.Duration + Shutdown func(ctx context.Context) error + Tasks []Task +} + +// Run coordinates tasks until the parent is canceled or one task stops. +// +// Run invokes Shutdown exactly once with a fresh, bounded context, then waits for every task. +// A plain parent context cancellation is a clean stop. Parent deadlines, custom cancellation +// causes, task failures, and shutdown failures are returned and remain compatible with errors.Is. +func Run(ctx context.Context, cfg Config) error { + if err := validate(ctx, cfg); err != nil { + return err + } + + if ctx.Err() != nil { + return errors.Join(parentError(ctx), shutdown(ctx, cfg)) + } + + group, runCtx := errgroup.WithContext(ctx) + for _, configuredTask := range cfg.Tasks { + task := configuredTask + task.Name = strings.TrimSpace(task.Name) + group.Go(func() error { + err := task.Run(runCtx) + if runCtx.Err() != nil { + return nil + } + if err == nil { + return fmt.Errorf("lifecycle: task %q: %w", task.Name, ErrTaskStopped) + } + return fmt.Errorf("lifecycle: task %q: %w", task.Name, err) + }) + } + + <-runCtx.Done() + shutdownErr := shutdown(ctx, cfg) + runErr := group.Wait() + return errors.Join(runErr, parentError(ctx), shutdownErr) +} + +func validate(ctx context.Context, cfg Config) error { + if ctx == nil { + return errors.New("lifecycle: context must not be nil") + } + if cfg.ShutdownTimeout <= 0 { + return errors.New("lifecycle: shutdown timeout must be positive") + } + if cfg.Shutdown == nil { + return errors.New("lifecycle: shutdown must not be nil") + } + if len(cfg.Tasks) == 0 { + return errors.New("lifecycle: at least one task is required") + } + + names := make(map[string]struct{}, len(cfg.Tasks)) + for _, task := range cfg.Tasks { + name := strings.TrimSpace(task.Name) + if name == "" { + return errors.New("lifecycle: task name must not be blank") + } + if _, exists := names[name]; exists { + return fmt.Errorf("lifecycle: duplicate task name %q", name) + } + if task.Run == nil { + return fmt.Errorf("lifecycle: task %q run function must not be nil", name) + } + names[name] = struct{}{} + } + return nil +} + +func shutdown(ctx context.Context, cfg Config) error { + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.ShutdownTimeout) + defer cancel() + return cfg.Shutdown(shutdownCtx) +} + +func parentError(ctx context.Context) error { + cause := context.Cause(ctx) + if cause == context.Canceled { + return nil + } + return cause +} diff --git a/lifecycle/lifecycle_test.go b/lifecycle/lifecycle_test.go new file mode 100644 index 0000000..1f946b2 --- /dev/null +++ b/lifecycle/lifecycle_test.go @@ -0,0 +1,287 @@ +package lifecycle_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/devctllabs/go-libs/lifecycle" + "github.com/stretchr/testify/require" +) + +func TestRunValidatesConfigBeforeStartingAnything(t *testing.T) { + t.Parallel() + + validTask := lifecycle.Task{Name: "api", Run: func(context.Context) error { return nil }} + validShutdown := func(context.Context) error { return nil } + tests := []struct { + name string + ctx context.Context + cfg lifecycle.Config + }{ + {name: "nil context", cfg: lifecycle.Config{ShutdownTimeout: time.Second, Shutdown: validShutdown, Tasks: []lifecycle.Task{validTask}}}, + {name: "zero timeout", ctx: context.Background(), cfg: lifecycle.Config{Shutdown: validShutdown, Tasks: []lifecycle.Task{validTask}}}, + {name: "negative timeout", ctx: context.Background(), cfg: lifecycle.Config{ShutdownTimeout: -time.Second, Shutdown: validShutdown, Tasks: []lifecycle.Task{validTask}}}, + {name: "nil shutdown", ctx: context.Background(), cfg: lifecycle.Config{ShutdownTimeout: time.Second, Tasks: []lifecycle.Task{validTask}}}, + {name: "no tasks", ctx: context.Background(), cfg: lifecycle.Config{ShutdownTimeout: time.Second, Shutdown: validShutdown}}, + {name: "blank task name", ctx: context.Background(), cfg: lifecycle.Config{ShutdownTimeout: time.Second, Shutdown: validShutdown, Tasks: []lifecycle.Task{{Name: " ", Run: validTask.Run}}}}, + {name: "duplicate task name", ctx: context.Background(), cfg: lifecycle.Config{ShutdownTimeout: time.Second, Shutdown: validShutdown, Tasks: []lifecycle.Task{validTask, validTask}}}, + {name: "nil task run", ctx: context.Background(), cfg: lifecycle.Config{ShutdownTimeout: time.Second, Shutdown: validShutdown, Tasks: []lifecycle.Task{{Name: "api"}}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var taskCalls atomic.Int32 + var shutdownCalls atomic.Int32 + for index := range tt.cfg.Tasks { + if tt.cfg.Tasks[index].Run != nil { + tt.cfg.Tasks[index].Run = func(context.Context) error { + taskCalls.Add(1) + return nil + } + } + } + if tt.cfg.Shutdown != nil { + tt.cfg.Shutdown = func(context.Context) error { + shutdownCalls.Add(1) + return nil + } + } + + err := lifecycle.Run(tt.ctx, tt.cfg) + + require.Error(t, err) + require.Zero(t, taskCalls.Load()) + require.Zero(t, shutdownCalls.Load()) + }) + } +} + +func TestRunTreatsTaskReturningNilAsUnexpectedStop(t *testing.T) { + t.Parallel() + + var shutdownCalls atomic.Int32 + err := lifecycle.Run(context.Background(), lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(context.Context) error { + shutdownCalls.Add(1) + return nil + }, + Tasks: []lifecycle.Task{{Name: "api", Run: func(context.Context) error { return nil }}}, + }) + + require.ErrorIs(t, err, lifecycle.ErrTaskStopped) + require.ErrorContains(t, err, "api") + require.Equal(t, int32(1), shutdownCalls.Load()) +} + +func TestRunCancelsSiblingsAndShutsDownWithFreshContext(t *testing.T) { + t.Parallel() + + type contextKey string + const key contextKey = "request" + taskErr := errors.New("serve API") + parent := context.WithValue(context.Background(), key, "value") + siblingStarted := make(chan struct{}) + var siblingStopped atomic.Bool + var shutdownCalls atomic.Int32 + + err := lifecycle.Run(parent, lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(ctx context.Context) error { + shutdownCalls.Add(1) + require.NoError(t, ctx.Err()) + require.Equal(t, "value", ctx.Value(key)) + _, hasDeadline := ctx.Deadline() + require.True(t, hasDeadline) + return nil + }, + Tasks: []lifecycle.Task{ + { + Name: "api", + Run: func(context.Context) error { + <-siblingStarted + return taskErr + }, + }, + { + Name: "consumer", + Run: func(ctx context.Context) error { + close(siblingStarted) + <-ctx.Done() + siblingStopped.Store(true) + return ctx.Err() + }, + }, + }, + }) + + require.ErrorIs(t, err, taskErr) + require.ErrorContains(t, err, "api") + require.NotContains(t, err.Error(), "consumer") + require.Equal(t, int32(1), shutdownCalls.Load()) + require.True(t, siblingStopped.Load()) +} + +func TestRunCallsShutdownBeforeWaitingForTasks(t *testing.T) { + t.Parallel() + + serverStarted := make(chan struct{}) + stopServer := make(chan struct{}) + errSentinel := errors.New("consumer failed") + err := lifecycle.Run(context.Background(), lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(context.Context) error { + close(stopServer) + return nil + }, + Tasks: []lifecycle.Task{ + {Name: "server", Run: func(context.Context) error { + close(serverStarted) + <-stopServer + return nil + }}, + {Name: "consumer", Run: func(context.Context) error { + <-serverStarted + return errSentinel + }}, + }, + }) + + require.ErrorIs(t, err, errSentinel) +} + +func TestRunTreatsParentCancellationAsCleanStop(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(context.Context) error { return nil }, + Tasks: []lifecycle.Task{{Name: "api", Run: func(ctx context.Context) error { + close(started) + <-ctx.Done() + return ctx.Err() + }}}, + }) + }() + <-started + cancel() + + require.NoError(t, <-done) +} + +func TestRunWithAlreadyCanceledParentSkipsTasksAndShutsDown(t *testing.T) { + t.Parallel() + + type contextKey string + const key contextKey = "request" + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), key, "value")) + cancel() + var taskCalls atomic.Int32 + var shutdownCalls atomic.Int32 + + err := lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(shutdownCtx context.Context) error { + shutdownCalls.Add(1) + require.NoError(t, shutdownCtx.Err()) + require.Equal(t, "value", shutdownCtx.Value(key)) + return nil + }, + Tasks: []lifecycle.Task{{Name: "api", Run: func(context.Context) error { + taskCalls.Add(1) + return nil + }}}, + }) + + require.NoError(t, err) + require.Zero(t, taskCalls.Load()) + require.Equal(t, int32(1), shutdownCalls.Load()) +} + +func TestRunPreservesNonCancellationParentCauses(t *testing.T) { + t.Parallel() + + t.Run("deadline", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + err := lifecycle.Run(ctx, waitingConfig()) + require.ErrorIs(t, err, context.DeadlineExceeded) + }) + + t.Run("custom cause", func(t *testing.T) { + t.Parallel() + cause := errors.New("terminate deployment") + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(cause) + var taskCalls atomic.Int32 + cfg := waitingConfig() + cfg.Tasks[0].Run = func(context.Context) error { + taskCalls.Add(1) + return nil + } + err := lifecycle.Run(ctx, cfg) + require.ErrorIs(t, err, cause) + require.Zero(t, taskCalls.Load()) + }) +} + +func TestRunJoinsTaskAndShutdownErrors(t *testing.T) { + t.Parallel() + + taskErr := errors.New("serve") + shutdownErr := errors.New("shutdown") + err := lifecycle.Run(context.Background(), lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(context.Context) error { return shutdownErr }, + Tasks: []lifecycle.Task{{Name: "api", Run: func(context.Context) error { + return taskErr + }}}, + }) + + require.ErrorIs(t, err, taskErr) + require.ErrorIs(t, err, shutdownErr) +} + +func TestRunSuppressesTaskErrorsReturnedAfterCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + started := make(chan struct{}) + taskErr := errors.New("listener closed") + done := make(chan error, 1) + go func() { + done <- lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(context.Context) error { return nil }, + Tasks: []lifecycle.Task{{Name: "api", Run: func(ctx context.Context) error { + close(started) + <-ctx.Done() + return taskErr + }}}, + }) + }() + <-started + cancel() + + require.NoError(t, <-done) +} + +func waitingConfig() lifecycle.Config { + return lifecycle.Config{ + ShutdownTimeout: time.Second, + Shutdown: func(context.Context) error { return nil }, + Tasks: []lifecycle.Task{{Name: "worker", Run: func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }}}, + } +} diff --git a/log/doc.go b/log/doc.go new file mode 100644 index 0000000..66906d3 --- /dev/null +++ b/log/doc.go @@ -0,0 +1,26 @@ +// Package log constructs JSON-encoded zap loggers for application diagnostics. +// +// New always uses zap's production JSON encoder with ISO 8601 timestamps. Log +// entries go to stderr by default, and enabling stacktraces adds them only to +// Error-level and higher entries. The package does not provide a plain-text +// mode or install a global zap logger. +// +// # Integration +// +// Pass the returned *zap.Logger explicitly through constructors. Do not wrap it +// in a custom logger interface solely for dependency injection, and do not use +// zap.L, zap.S, or zap.ReplaceGlobals. Add application-wide fields once when +// constructing the logger and use Named for static component identity. +// +// The caller owns the logger lifecycle. Call Sync when required by the selected +// output and handle output-specific Sync errors at the application boundary. +// WithOutput replaces stderr for tests or integrations; it does not close the +// supplied writer. +// +// # Testing +// +// Prefer zap.NewNop for ordinary unit tests, zaptest.NewLogger when logs should +// be attached to testing.T, and zaptest/observer when a test asserts structured +// entries. Use WithOutput when testing this package's concrete JSON encoding or +// an application logger factory. +package log diff --git a/log/example_test.go b/log/example_test.go new file mode 100644 index 0000000..e5d81c6 --- /dev/null +++ b/log/example_test.go @@ -0,0 +1,35 @@ +package log_test + +import ( + "bytes" + "encoding/json" + stdlog "log" + "os" + + applog "github.com/devctllabs/go-libs/log" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func ExampleNew() { + var output bytes.Buffer + logger := applog.New( + zapcore.InfoLevel, + false, + applog.WithOutput(&output), + ) + logger.Info("started", zap.String("service", "api")) + + var entry struct { + Level string `json:"level"` + Message string `json:"msg"` + Service string `json:"service"` + } + if err := json.Unmarshal(output.Bytes(), &entry); err != nil { + stdlog.Fatal(err) + } + + outputLogger := stdlog.New(os.Stdout, "", 0) + outputLogger.Printf("level=%s message=%s service=%s", entry.Level, entry.Message, entry.Service) + // Output: level=info message=started service=api +} diff --git a/log/go.mod b/log/go.mod new file mode 100644 index 0000000..7eb808c --- /dev/null +++ b/log/go.mod @@ -0,0 +1,15 @@ +module github.com/devctllabs/go-libs/log + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.28.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/log/go.sum b/log/go.sum new file mode 100644 index 0000000..b6e80bf --- /dev/null +++ b/log/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..12cc940 --- /dev/null +++ b/log/log.go @@ -0,0 +1,57 @@ +package log + +import ( + "io" + "os" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type config struct { + output io.Writer +} + +// Option configures a logger created by New. Callers use the provided With... +// functions rather than implementing Option directly. +type Option func(*config) + +// WithOutput directs log entries to output. A nil output leaves the current +// destination unchanged. When supplied more than once, the last non-nil output +// wins. The logger serializes writes but does not close output. +func WithOutput(output io.Writer) Option { + return func(cfg *config) { + if output != nil { + cfg.output = output + } + } +} + +// New constructs a JSON logger that emits entries at level and above. It uses +// ISO 8601 timestamps and writes to stderr unless WithOutput overrides the +// destination. When stacktrace is true, Error-level and higher entries include +// a stacktrace. New ignores nil options and does not replace zap's global +// loggers. +func New(level zapcore.Level, stacktrace bool, options ...Option) *zap.Logger { + cfg := config{output: os.Stderr} + for _, option := range options { + if option != nil { + option(&cfg) + } + } + + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + + core := zapcore.NewCore( + zapcore.NewJSONEncoder(encoderCfg), + zapcore.Lock(zapcore.AddSync(cfg.output)), + level, + ) + + if stacktrace { + return zap.New(core, zap.AddStacktrace(zapcore.ErrorLevel)) + } + + return zap.New(core) +} diff --git a/log/log_test.go b/log/log_test.go new file mode 100644 index 0000000..9a93696 --- /dev/null +++ b/log/log_test.go @@ -0,0 +1,184 @@ +package log_test + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/devctllabs/go-libs/log" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestNewWritesJSONToConfiguredOutput(t *testing.T) { + t.Parallel() + var output bytes.Buffer + + logger := log.New(zapcore.InfoLevel, false, log.WithOutput(&output)) + logger.Info("started", zap.String("service", "api")) + + entries := decodeEntries(t, output.Bytes()) + require.Len(t, entries, 1) + entry := entries[0] + require.Equal(t, "info", entry["level"]) + require.Equal(t, "started", entry["msg"]) + require.Equal(t, "api", entry["service"]) + + timestamp, ok := entry["ts"].(string) + require.True(t, ok) + _, err := time.Parse("2006-01-02T15:04:05.000Z0700", timestamp) + require.NoError(t, err) +} + +func TestNewFiltersEntriesBelowLevel(t *testing.T) { + t.Parallel() + var output bytes.Buffer + + logger := log.New(zapcore.ErrorLevel, false, log.WithOutput(&output)) + logger.Debug("debug") + logger.Info("info") + logger.Warn("warn") + logger.Error("error") + + entries := decodeEntries(t, output.Bytes()) + require.Len(t, entries, 1) + require.Equal(t, "error", entries[0]["level"]) + require.Equal(t, "error", entries[0]["msg"]) +} + +func TestNewConfiguresStacktraces(t *testing.T) { + t.Parallel() + tests := []struct { + name string + stacktrace bool + entryLevel zapcore.Level + wantStack bool + }{ + { + name: "disabled for error", + stacktrace: false, + entryLevel: zapcore.ErrorLevel, + wantStack: false, + }, + { + name: "enabled below error", + stacktrace: true, + entryLevel: zapcore.InfoLevel, + wantStack: false, + }, + { + name: "enabled for error", + stacktrace: true, + entryLevel: zapcore.ErrorLevel, + wantStack: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + + logger := log.New(zapcore.DebugLevel, tt.stacktrace, log.WithOutput(&output)) + logger.Log(tt.entryLevel, "message") + + entries := decodeEntries(t, output.Bytes()) + require.Len(t, entries, 1) + if tt.wantStack { + require.NotEmpty(t, entries[0]["stacktrace"]) + return + } + require.NotContains(t, entries[0], "stacktrace") + }) + } +} + +//nolint:paralleltest // The test temporarily replaces process-global os.Stderr. +func TestNewWritesToStderrByDefault(t *testing.T) { + originalStderr := os.Stderr + reader, writer, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { + os.Stderr = originalStderr + _ = reader.Close() + _ = writer.Close() + }) + + os.Stderr = writer + logger := log.New(zapcore.InfoLevel, false) + os.Stderr = originalStderr + + logger.Info("message") + require.NoError(t, writer.Close()) + output, err := io.ReadAll(reader) + require.NoError(t, err) + + entries := decodeEntries(t, output) + require.Len(t, entries, 1) + require.Equal(t, "message", entries[0]["msg"]) +} + +func TestWithOutputIgnoresNil(t *testing.T) { + t.Parallel() + var output bytes.Buffer + + logger := log.New( + zapcore.InfoLevel, + false, + log.WithOutput(&output), + log.WithOutput(nil), + ) + logger.Info("message") + + entries := decodeEntries(t, output.Bytes()) + require.Len(t, entries, 1) + require.Equal(t, "message", entries[0]["msg"]) +} + +func TestWithOutputUsesLastNonNilOutput(t *testing.T) { + t.Parallel() + var firstOutput bytes.Buffer + var secondOutput bytes.Buffer + + logger := log.New( + zapcore.InfoLevel, + false, + log.WithOutput(&firstOutput), + log.WithOutput(&secondOutput), + ) + logger.Info("message") + + require.Empty(t, firstOutput.Bytes()) + entries := decodeEntries(t, secondOutput.Bytes()) + require.Len(t, entries, 1) + require.Equal(t, "message", entries[0]["msg"]) +} + +func TestNewDoesNotReplaceGlobalLoggers(t *testing.T) { + t.Parallel() + globalLogger := zap.L() + globalSugar := zap.S() + + _ = log.New(zapcore.InfoLevel, false, log.WithOutput(io.Discard)) + + require.Same(t, globalLogger, zap.L()) + require.Same(t, globalSugar, zap.S()) +} + +func decodeEntries(t *testing.T, output []byte) []map[string]any { + t.Helper() + + lines := bytes.Split(bytes.TrimSpace(output), []byte{'\n'}) + entries := make([]map[string]any, 0, len(lines)) + for _, line := range lines { + var entry map[string]any + require.NoError(t, json.Unmarshal(line, &entry)) + entries = append(entries, entry) + } + + return entries +} diff --git a/mise.lock b/mise.lock new file mode 100644 index 0000000..812f8c5 --- /dev/null +++ b/mise.lock @@ -0,0 +1,158 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.git-cliff]] +version = "2.13.1" +backend = "aqua:orhun/git-cliff" + +[tools.git-cliff."platforms.linux-arm64"] +checksum = "sha256:4054c124b926c117f3fa048939bc8be0a954f29f3b6f367627e8cb22c1971882" +url = "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/orhun/git-cliff/releases/assets/405850720" + +[tools.git-cliff."platforms.linux-arm64-musl"] +checksum = "sha256:4054c124b926c117f3fa048939bc8be0a954f29f3b6f367627e8cb22c1971882" +url = "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/orhun/git-cliff/releases/assets/405850720" + +[tools.git-cliff."platforms.linux-x64"] +checksum = "sha256:200d2535da6d9703f3bcc8a4d159c3b55eacdb01cf2148c55b3eee9dd04d5249" +url = "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/orhun/git-cliff/releases/assets/405850671" + +[tools.git-cliff."platforms.linux-x64-musl"] +checksum = "sha256:200d2535da6d9703f3bcc8a4d159c3b55eacdb01cf2148c55b3eee9dd04d5249" +url = "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/orhun/git-cliff/releases/assets/405850671" + +[tools.git-cliff."platforms.macos-arm64"] +checksum = "sha256:21547ae4a0421164070ab75c2522864ea5565858a011fabc5f583061b20f1226" +url = "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/orhun/git-cliff/releases/assets/405849885" + +[tools.git-cliff."platforms.macos-x64"] +checksum = "sha256:6e60ae390d375cecb9d8008c49f0e724a8dfe40390b532ef5501e421d2cc8acb" +url = "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/orhun/git-cliff/releases/assets/405850193" + +[tools.git-cliff."platforms.windows-x64"] +checksum = "sha256:3ae3a5549e85c7ad5b20192ebcfee4371269deca51255f6f2f2e051c6541f5ca" +url = "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/orhun/git-cliff/releases/assets/405851866" + +[[tools.go]] +version = "1.25.0" +backend = "core:go" + +[tools.go."platforms.linux-arm64"] +checksum = "sha256:05de75d6994a2783699815ee553bd5a9327d8b79991de36e38b66862782f54ae" +url = "https://dl.google.com/go/go1.25.0.linux-arm64.tar.gz" + +[tools.go."platforms.linux-arm64-musl"] +checksum = "sha256:05de75d6994a2783699815ee553bd5a9327d8b79991de36e38b66862782f54ae" +url = "https://dl.google.com/go/go1.25.0.linux-arm64.tar.gz" + +[tools.go."platforms.linux-x64"] +checksum = "sha256:2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613" +url = "https://dl.google.com/go/go1.25.0.linux-amd64.tar.gz" + +[tools.go."platforms.linux-x64-musl"] +checksum = "sha256:2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613" +url = "https://dl.google.com/go/go1.25.0.linux-amd64.tar.gz" + +[tools.go."platforms.macos-arm64"] +checksum = "sha256:544932844156d8172f7a28f77f2ac9c15a23046698b6243f633b0a0b00c0749c" +url = "https://dl.google.com/go/go1.25.0.darwin-arm64.tar.gz" + +[tools.go."platforms.macos-x64"] +checksum = "sha256:5bd60e823037062c2307c71e8111809865116714d6f6b410597cf5075dfd80ef" +url = "https://dl.google.com/go/go1.25.0.darwin-amd64.tar.gz" + +[tools.go."platforms.windows-x64"] +checksum = "sha256:89efb4f9b30812eee083cc1770fdd2913c14d301064f6454851428f9707d190b" +url = "https://dl.google.com/go/go1.25.0.windows-amd64.zip" + +[[tools.golangci-lint]] +version = "2.12.2" +backend = "aqua:golangci/golangci-lint" + +[tools.golangci-lint."platforms.linux-arm64"] +checksum = "sha256:44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413470996" +provenance = "github-attestations" + +[tools.golangci-lint."platforms.linux-arm64-musl"] +checksum = "sha256:44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413470996" +provenance = "github-attestations" + +[tools.golangci-lint."platforms.linux-x64"] +checksum = "sha256:8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413471054" +provenance = "github-attestations" + +[tools.golangci-lint."platforms.linux-x64-musl"] +checksum = "sha256:8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-linux-amd64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413471054" +provenance = "github-attestations" + +[tools.golangci-lint."platforms.macos-arm64"] +checksum = "sha256:a9c54498731b3128f79e090be6110f3e5fffccc617b08142ed244d4126c73f29" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413470950" +provenance = "github-attestations" + +[tools.golangci-lint."platforms.macos-x64"] +checksum = "sha256:f6f06d94b6241521c53d15450c5209b028270bf966f842afb11c030c79f5bc16" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-darwin-amd64.tar.gz" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413470980" +provenance = "github-attestations" + +[tools.golangci-lint."platforms.windows-x64"] +checksum = "sha256:bd42e3ebc8cb4ececb86941983baaf1dc221bbb04d838e94ce63b49cc91e02bb" +url = "https://github.com/golangci/golangci-lint/releases/download/v2.12.2/golangci-lint-2.12.2-windows-amd64.zip" +url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/413471017" +provenance = "github-attestations" + +[[tools.node]] +version = "24.19.0" +backend = "core:node" + +[tools.node."platforms.linux-arm64"] +checksum = "sha256:d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f" +url = "https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.gz" + +[tools.node."platforms.linux-arm64-musl"] +checksum = "sha256:20824e4d35948fae5b337dccef47813b04d8995312f59df7386f2256d9f9ab7e" +url = "https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-arm64-musl.tar.gz" + +[tools.node."platforms.linux-x64"] +checksum = "sha256:f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4" +url = "https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.gz" + +[tools.node."platforms.linux-x64-musl"] +checksum = "sha256:c60223786df14a5d23e220ebb8e60318f5322640a62f90e6d9e54d3a18da532e" +url = "https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-x64-musl.tar.gz" + +[tools.node."platforms.macos-arm64"] +checksum = "sha256:8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d" +url = "https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.gz" + +[tools.node."platforms.macos-x64"] +checksum = "sha256:d1b5e999db158c62fe8f7267a4476b035d8bd93b1a605bac24a3f0dd166e3316" +url = "https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.gz" + +[tools.node."platforms.windows-x64"] +checksum = "sha256:57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73" +url = "https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip" + +[[tools."npm:@openai/codex"]] +version = "0.146.0" +backend = "npm:@openai/codex" + +[[tools."npm:quicktype"]] +version = "26.0.0" +backend = "npm:quicktype" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..54c626b --- /dev/null +++ b/mise.toml @@ -0,0 +1,49 @@ +[settings] +lockfile = true + +[tools] +go = "1.25.0" +golangci-lint = "2.12.2" +git-cliff = "2.13.1" +node = "24" +"npm:@openai/codex" = "0.146.0" +"npm:quicktype" = "26.0.0" + +[tasks."codexapp:generate"] +description = "Refresh the checked-in Codex App Server protocol snapshot and Go types" +run = "go generate ./codexapp/..." + +[tasks."codexapp:check-generated"] +description = "Verify the checked-in Codex App Server protocol artifacts" +run = "go run ./codexapp/internal/protocol/cmd/generate -check" + +[tasks."codexapp:test"] +description = "Run codexapp tests" +run = "go test ./codexapp/..." + +[tasks.test] +description = "Run tests for all Go modules" +run = "go test ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./health/... ./healthotel/... ./healthserver/... ./healthzap/... ./lifecycle/... ./log/... ./oapivalidator/... ./postgresdb/... ./sqlitedb/... ./telemetry/... ./txmanager/..." + +[tasks.lint] +description = "Run golangci-lint for all Go modules" +run = "golangci-lint run ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./health/... ./healthotel/... ./healthserver/... ./healthzap/... ./lifecycle/... ./log/... ./oapivalidator/... ./postgresdb/... ./sqlitedb/... ./telemetry/... ./txmanager/..." + +[tasks."test:race"] +description = "Run race-enabled tests for all Go modules" +run = "go test -race ./codexapp/... ./config/... ./debugserver/... ./di/... ./filesystem/... ./health/... ./healthotel/... ./healthserver/... ./healthzap/... ./lifecycle/... ./log/... ./oapivalidator/... ./postgresdb/... ./sqlitedb/... ./telemetry/... ./txmanager/..." + +[tasks."postgresdb:test-integration"] +description = "Run race-enabled PostgreSQL integration tests" +run = "go test -race -tags=integration ./postgresdb/..." + +[tasks.generate] +description = "Refresh all checked-in generated Go artifacts" +run = "go generate ./codexapp/... ./config/... ./filesystem/... ./health/... ./healthserver/... ./oapivalidator/... ./txmanager/..." + +[tasks."check-generated"] +description = "Verify all checked-in generated Go artifacts" +run = """ +mise run generate +git diff --exit-code +""" diff --git a/oapivalidator/authentication.go b/oapivalidator/authentication.go new file mode 100644 index 0000000..346f6f7 --- /dev/null +++ b/oapivalidator/authentication.go @@ -0,0 +1,51 @@ +package oapivalidator + +import ( + "context" + "errors" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" +) + +// ErrUnauthenticated reports missing or invalid credentials. +var ErrUnauthenticated = errors.New("unauthenticated") + +// ErrForbidden reports valid credentials without the required access. +var ErrForbidden = errors.New("forbidden") + +// AuthenticationInput describes one security scheme in the current OpenAPI +// security requirement. +type AuthenticationInput struct { + Request *http.Request + OperationID string + SecuritySchemeName string + SecurityScheme *openapi3.SecurityScheme + Scopes []string +} + +// Authenticator validates one OpenAPI security scheme at a time. +type Authenticator interface { + // Authenticate validates the requested scheme and returns the context that + // subsequent schemes and the endpoint handler receive. + Authenticate(ctx context.Context, input AuthenticationInput) (nextCtx context.Context, err error) +} + +// AuthenticatorFunc adapts a function to Authenticator. +type AuthenticatorFunc func(ctx context.Context, input AuthenticationInput) (nextCtx context.Context, err error) + +// Authenticate implements Authenticator. +func (fn AuthenticatorFunc) Authenticate(ctx context.Context, input AuthenticationInput) (context.Context, error) { + return fn(ctx, input) +} + +type authenticationError struct { + cause error + scheme *openapi3.SecurityScheme +} + +func (*authenticationError) Error() string { return "openapi authentication failed" } + +func (failure *authenticationError) Unwrap() error { return failure.cause } + +var errNilAuthenticationContext = errors.New("authenticator returned a nil context without an error") diff --git a/oapivalidator/authentication_test.go b/oapivalidator/authentication_test.go new file mode 100644 index 0000000..1a52d9e --- /dev/null +++ b/oapivalidator/authentication_test.go @@ -0,0 +1,284 @@ +package oapivalidator_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/devctllabs/go-libs/oapivalidator" + "github.com/devctllabs/go-libs/oapivalidator/mocks" +) + +type contextKey string + +func TestNewRequiresAuthenticatorOnlyForMandatorySecurity(t *testing.T) { + t.Parallel() + _, err := oapivalidator.New(loadDocument(t, securedDocument)) + require.Error(t, err) + + middleware, err := oapivalidator.New(loadDocument(t, optionalSecurityDocument)) + require.NoError(t, err) + recorder := serve(t, middleware, http.MethodGet, "/optional", "", "", nil) + require.Equal(t, http.StatusNoContent, recorder.Code) +} + +func TestAuthenticatorReceivesSchemeScopesAndEnrichesHandlerContext(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + first := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + require.Equal(t, "andSecurity", input.OperationID) + require.Equal(t, "apiKey", input.SecuritySchemeName) + require.Empty(t, input.Scopes) + return context.WithValue(ctx, contextKey("api-key"), "accepted"), nil + }, + ) + authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).After(first).DoAndReturn( + func(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + require.Equal(t, "bearerAuth", input.SecuritySchemeName) + require.Equal(t, []string{"widgets:read"}, input.Scopes) + require.Equal(t, "accepted", ctx.Value(contextKey("api-key"))) + return context.WithValue(ctx, contextKey("subject"), "user-1"), nil + }, + ) + middleware, err := oapivalidator.New(loadDocument(t, securedDocument), oapivalidator.WithAuthenticator(authenticator)) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/and", "", "", func(c *echo.Context) error { + require.Equal(t, "accepted", c.Request().Context().Value(contextKey("api-key"))) + require.Equal(t, "user-1", c.Request().Context().Value(contextKey("subject"))) + return c.NoContent(http.StatusNoContent) + }) + + require.Equal(t, http.StatusNoContent, recorder.Code) +} + +func TestAuthenticatorAccumulatesContextAcrossFailedSecurityAlternative(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + apiKey := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + require.Equal(t, "apiKey", input.SecuritySchemeName) + return context.WithValue(ctx, contextKey("first-alternative"), "enriched"), nil + }, + ) + bearer := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).After(apiKey).DoAndReturn( + func(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + require.Equal(t, "bearerAuth", input.SecuritySchemeName) + require.Equal(t, "enriched", ctx.Value(contextKey("first-alternative"))) + return ctx, oapivalidator.ErrUnauthenticated + }, + ) + authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).After(bearer).DoAndReturn( + func(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + require.Equal(t, "cookieKey", input.SecuritySchemeName) + require.Equal(t, "enriched", ctx.Value(contextKey("first-alternative"))) + return context.WithValue(ctx, contextKey("subject"), "fallback-user"), nil + }, + ) + middleware, err := oapivalidator.New(loadDocument(t, securedDocument), oapivalidator.WithAuthenticator(authenticator)) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/fallback", "", "", func(c *echo.Context) error { + require.Equal(t, "enriched", c.Request().Context().Value(contextKey("first-alternative"))) + require.Equal(t, "fallback-user", c.Request().Context().Value(contextKey("subject"))) + return c.NoContent(http.StatusNoContent) + }) + + require.Equal(t, http.StatusNoContent, recorder.Code) +} + +func TestAuthenticationFailureMapping(t *testing.T) { + t.Parallel() + tests := []struct { + name string + authError error + status int + challenge string + }{ + {name: "unauthenticated", authError: fmt.Errorf("wrapped: %w", oapivalidator.ErrUnauthenticated), status: http.StatusUnauthorized, challenge: "Bearer"}, + {name: "forbidden", authError: fmt.Errorf("wrapped: %w", oapivalidator.ErrForbidden), status: http.StatusForbidden}, + {name: "backend failure", authError: errors.New("identity provider unavailable"), status: http.StatusInternalServerError}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).Return(context.Background(), test.authError) + middleware, err := oapivalidator.New(loadDocument(t, securedDocument), oapivalidator.WithAuthenticator(authenticator)) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/secure", "", "", nil) + + require.Equal(t, test.status, recorder.Code) + require.Equal(t, test.challenge, recorder.Header().Get("WWW-Authenticate")) + require.NotContains(t, recorder.Body.String(), "identity provider") + }) + } +} + +func TestAuthenticatorRejectsNilSuccessContext(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).Return(nil, nil) + middleware, err := oapivalidator.New(loadDocument(t, securedDocument), oapivalidator.WithAuthenticator(authenticator)) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/secure", "", "", nil) + + require.Equal(t, http.StatusInternalServerError, recorder.Code) +} + +func TestAuthenticationFailurePriorityIsInternalThenForbiddenThenUnauthenticated(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + first := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).Return(context.Background(), oapivalidator.ErrUnauthenticated) + second := authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).After(first).Return(context.Background(), oapivalidator.ErrForbidden) + authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).After(second).Return(context.Background(), errors.New("backend unavailable")) + middleware, err := oapivalidator.New(loadDocument(t, priorityDocument), oapivalidator.WithAuthenticator(authenticator)) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/priority", "", "", nil) + + require.Equal(t, http.StatusInternalServerError, recorder.Code) +} + +func TestMiddlewareDoesNotLeakAuthenticationContextBetweenConcurrentRequests(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + requestID := input.Request.Header.Get("X-Request-ID") + return context.WithValue(ctx, contextKey("request-id"), requestID), nil + }, + ) + middleware, err := oapivalidator.New(loadDocument(t, securedDocument), oapivalidator.WithAuthenticator(authenticator)) + require.NoError(t, err) + + const requestCount = 32 + errorsChannel := make(chan error, requestCount) + var waitGroup sync.WaitGroup + for index := range requestCount { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + requestID := fmt.Sprintf("request-%d", index) + request := httptest.NewRequest(http.MethodGet, "/secure", nil) + request.Header.Set("X-Request-ID", requestID) + recorder := httptest.NewRecorder() + context := echo.New().NewContext(request, recorder) + err := middleware(func(c *echo.Context) error { + actual, _ := c.Request().Context().Value(contextKey("request-id")).(string) + if actual != requestID { + return fmt.Errorf("context request ID = %q, want %q", actual, requestID) + } + return c.NoContent(http.StatusNoContent) + })(context) + if err == nil && recorder.Code != http.StatusNoContent { + err = fmt.Errorf("status = %d, want %d", recorder.Code, http.StatusNoContent) + } + errorsChannel <- err + }() + } + waitGroup.Wait() + close(errorsChannel) + + for err := range errorsChannel { + require.NoError(t, err) + } +} + +const securedDocument = ` +openapi: 3.1.0 +info: {title: Secured API, version: 1.0.0} +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-API-Key + bearerAuth: + type: http + scheme: bearer + cookieKey: + type: apiKey + in: cookie + name: session +security: + - bearerAuth: [] +paths: + /secure: + get: + operationId: secure + responses: + "204": {description: OK} + /and: + get: + operationId: andSecurity + security: + - apiKey: [] + bearerAuth: [widgets:read] + responses: + "204": {description: OK} + /fallback: + get: + operationId: fallbackSecurity + security: + - apiKey: [] + bearerAuth: [] + - cookieKey: [] + responses: + "204": {description: OK} +` + +const optionalSecurityDocument = ` +openapi: 3.1.0 +info: {title: Optional API, version: 1.0.0} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer} +paths: + /optional: + get: + operationId: optional + security: + - bearerAuth: [] + - {} + responses: + "204": {description: OK} +` + +const priorityDocument = ` +openapi: 3.1.0 +info: {title: Priority API, version: 1.0.0} +components: + securitySchemes: + first: {type: http, scheme: bearer} + second: {type: apiKey, in: header, name: X-API-Key} + third: {type: apiKey, in: cookie, name: session} +paths: + /priority: + get: + operationId: priority + security: + - first: [] + - second: [] + - third: [] + responses: + "204": {description: OK} +` diff --git a/oapivalidator/doc.go b/oapivalidator/doc.go new file mode 100644 index 0000000..ae642b8 --- /dev/null +++ b/oapivalidator/doc.go @@ -0,0 +1,6 @@ +// Package oapivalidator validates Echo requests against an OpenAPI document. +// +// It deliberately keeps generated oapi-codegen handlers transport-only: +// request validation and authentication run before the generated Echo wrapper, +// while application authorization remains in handwritten code. +package oapivalidator diff --git a/oapivalidator/failure.go b/oapivalidator/failure.go new file mode 100644 index 0000000..4cf5522 --- /dev/null +++ b/oapivalidator/failure.go @@ -0,0 +1,172 @@ +package oapivalidator + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v5" +) + +// FailureKind identifies a stable category of request validation failure. +type FailureKind string + +const ( + FailureNotFound FailureKind = "not_found" + FailureMethodNotAllowed FailureKind = "method_not_allowed" + FailureMalformedRequest FailureKind = "malformed_request" + FailureInvalidRequest FailureKind = "invalid_request" + FailureUnsupportedMediaType FailureKind = "unsupported_media_type" + FailureUnauthenticated FailureKind = "unauthenticated" + FailureForbidden FailureKind = "forbidden" + FailureInternal FailureKind = "internal" +) + +const ( + ProblemTypeNotFound = "urn:devctl:oapivalidator:problem:not-found" + ProblemTypeMethodNotAllowed = "urn:devctl:oapivalidator:problem:method-not-allowed" + ProblemTypeMalformedRequest = "urn:devctl:oapivalidator:problem:malformed-request" + ProblemTypeInvalidRequest = "urn:devctl:oapivalidator:problem:invalid-request" + ProblemTypeUnsupportedMediaType = "urn:devctl:oapivalidator:problem:unsupported-media-type" + ProblemTypeUnauthenticated = "urn:devctl:oapivalidator:problem:unauthenticated" + ProblemTypeForbidden = "urn:devctl:oapivalidator:problem:forbidden" + ProblemTypeInternal = "urn:devctl:oapivalidator:problem:internal" +) + +// Location identifies the part of the request containing an invalid value. +type Location string + +const ( + LocationBody Location = "body" + LocationPath Location = "path" + LocationQuery Location = "query" + LocationHeader Location = "header" + LocationCookie Location = "cookie" +) + +// FieldError is a safe, normalized request validation error. +type FieldError struct { + Code string `json:"code"` + Detail string `json:"detail"` + In Location `json:"in,omitempty"` + Pointer string `json:"pointer,omitempty"` + Parameter string `json:"parameter,omitempty"` +} + +// Problem is an RFC 9457 problem details response. +type Problem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail,omitempty"` + Instance string `json:"instance,omitempty"` + Errors []FieldError `json:"errors,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// Failure retains the private cause while exposing safe client diagnostics. +type Failure struct { + Kind FailureKind + Status int + OperationID string + Errors []FieldError + Truncated bool + Cause error + + wwwAuthenticate string +} + +// Error implements error without exposing the underlying validator message. +func (failure *Failure) Error() string { + if failure == nil { + return "" + } + return fmt.Sprintf("openapi request validation failed (%s)", failure.Kind) +} + +// Unwrap exposes the original failure to server-side errors.Is/errors.As. +func (failure *Failure) Unwrap() error { + if failure == nil { + return nil + } + return failure.Cause +} + +// Problem returns the safe RFC 9457 representation of failure. +func (failure *Failure) Problem() Problem { + return problemForFailure(failure) +} + +// FailureHandler handles a normalized validation failure. +type FailureHandler interface { + // Handle writes or returns the response for failure. + Handle(c *echo.Context, failure *Failure) error +} + +// FailureHandlerFunc adapts a function to FailureHandler. +type FailureHandlerFunc func(c *echo.Context, failure *Failure) error + +// Handle implements FailureHandler. +func (fn FailureHandlerFunc) Handle(c *echo.Context, failure *Failure) error { + return fn(c, failure) +} + +func problemForFailure(failure *Failure) Problem { + problemType, title, detail := problemMetadata(failure.Kind) + return Problem{ + Type: problemType, + Title: title, + Status: failure.Status, + Detail: detail, + Errors: failure.Errors, + Truncated: failure.Truncated, + } +} + +func problemMetadata(kind FailureKind) (problemType, title, detail string) { + switch kind { + case FailureNotFound: + return ProblemTypeNotFound, "Operation not found", "No OpenAPI operation matches this request." + case FailureMethodNotAllowed: + return ProblemTypeMethodNotAllowed, "Method not allowed", "The path does not support this HTTP method." + case FailureMalformedRequest: + return ProblemTypeMalformedRequest, "Malformed request", "The request could not be decoded." + case FailureInvalidRequest: + return ProblemTypeInvalidRequest, "Invalid request", "The request does not satisfy the API contract." + case FailureUnsupportedMediaType: + return ProblemTypeUnsupportedMediaType, "Unsupported media type", "The request content type is not supported." + case FailureUnauthenticated: + return ProblemTypeUnauthenticated, "Authentication required", "Valid credentials are required." + case FailureForbidden: + return ProblemTypeForbidden, "Forbidden", "The credentials do not grant the required access." + default: + return ProblemTypeInternal, "Internal server error", "The request could not be validated." + } +} + +type defaultFailureHandler struct{} + +func (defaultFailureHandler) Handle(c *echo.Context, failure *Failure) error { + c.Response().Header().Set("Content-Type", "application/problem+json") + return c.JSON(failure.Status, failure.Problem()) +} + +func statusForKind(kind FailureKind) int { + switch kind { + case FailureNotFound: + return http.StatusNotFound + case FailureMethodNotAllowed: + return http.StatusMethodNotAllowed + case FailureMalformedRequest: + return http.StatusBadRequest + case FailureInvalidRequest: + return http.StatusUnprocessableEntity + case FailureUnsupportedMediaType: + return http.StatusUnsupportedMediaType + case FailureUnauthenticated: + return http.StatusUnauthorized + case FailureForbidden: + return http.StatusForbidden + default: + return http.StatusInternalServerError + } +} diff --git a/oapivalidator/go.mod b/oapivalidator/go.mod new file mode 100644 index 0000000..9788726 --- /dev/null +++ b/oapivalidator/go.mod @@ -0,0 +1,39 @@ +module github.com/devctllabs/go-libs/oapivalidator + +go 1.25.0 + +require ( + github.com/getkin/kin-openapi v0.142.0 + github.com/labstack/echo/v5 v5.1.1 + github.com/oapi-codegen/runtime v1.6.0 + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +tool go.uber.org/mock/mockgen diff --git a/oapivalidator/go.sum b/oapivalidator/go.sum new file mode 100644 index 0000000..f1ecdfd --- /dev/null +++ b/oapivalidator/go.sum @@ -0,0 +1,181 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I= +github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/oapivalidator/integration_test.go b/oapivalidator/integration_test.go new file mode 100644 index 0000000..b8003f3 --- /dev/null +++ b/oapivalidator/integration_test.go @@ -0,0 +1,60 @@ +package oapivalidator_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/devctllabs/go-libs/oapivalidator" + "github.com/devctllabs/go-libs/oapivalidator/internal/testapi" + "github.com/devctllabs/go-libs/oapivalidator/mocks" +) + +func TestGeneratedEcho5StrictServerReceivesAuthenticatedContext(t *testing.T) { + t.Parallel() + document, err := testapi.GetSpec() + require.NoError(t, err) + + controller := gomock.NewController(t) + authenticator := mocks.NewMockAuthenticator(controller) + authenticator.EXPECT().Authenticate(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + require.Equal(t, "GetFixture", input.OperationID) + return context.WithValue(ctx, contextKey("subject"), "fixture-user"), nil + }, + ) + middleware, err := oapivalidator.New( + document, + oapivalidator.WithAuthenticator(authenticator), + oapivalidator.WithBaseURL("/api"), + ) + require.NoError(t, err) + + seenContext := make(chan context.Context, 1) + server := fixtureServer{seenContext: seenContext} + e := echo.New() + e.Use(middleware) + testapi.RegisterHandlersWithOptions(e, testapi.NewStrictHandler(server, nil), testapi.RegisterHandlersOptions{BaseURL: "/api"}) + + request := httptest.NewRequest(http.MethodGet, "/api/fixture/fixture-1", nil) + recorder := httptest.NewRecorder() + e.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNoContent, recorder.Code) + strictContext := <-seenContext + require.Equal(t, "fixture-user", strictContext.Value(contextKey("subject"))) +} + +type fixtureServer struct { + seenContext chan<- context.Context +} + +func (server fixtureServer) GetFixture(ctx context.Context, _ testapi.GetFixtureRequestObject) (testapi.GetFixtureResponseObject, error) { + server.seenContext <- ctx + return testapi.GetFixture204Response{}, nil +} diff --git a/oapivalidator/internal/testapi/api.yaml b/oapivalidator/internal/testapi/api.yaml new file mode 100644 index 0000000..8df2b8e --- /dev/null +++ b/oapivalidator/internal/testapi/api.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: oapivalidator integration fixture + version: 1.0.0 +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +security: + - bearerAuth: [] +paths: + /fixture/{id}: + get: + operationId: GetFixture + parameters: + - name: id + in: path + required: true + schema: + type: string + const: fixture-1 + responses: + "204": + description: Found diff --git a/oapivalidator/internal/testapi/generate.go b/oapivalidator/internal/testapi/generate.go new file mode 100644 index 0000000..1a268f3 --- /dev/null +++ b/oapivalidator/internal/testapi/generate.go @@ -0,0 +1,4 @@ +// Package testapi contains an oapi-codegen Echo 5 strict-server fixture. +package testapi + +//go:generate go tool oapi-codegen -config server.yaml -o server.gen.go api.yaml diff --git a/oapivalidator/internal/testapi/server.gen.go b/oapivalidator/internal/testapi/server.gen.go new file mode 100644 index 0000000..ebac4a6 --- /dev/null +++ b/oapivalidator/internal/testapi/server.gen.go @@ -0,0 +1,280 @@ +// Package testapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package testapi + +import ( + "bytes" + "compress/flate" + "context" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/labstack/echo/v5" + "github.com/oapi-codegen/runtime" +) + +// Defines values for GetFixtureParamsId. +const ( + Fixture1 GetFixtureParamsId = "fixture-1" +) + +// Valid indicates whether the value is a known member of the GetFixtureParamsId enum. +func (e GetFixtureParamsId) Valid() bool { + switch e { + case Fixture1: + return true + default: + return false + } +} + +// GetFixtureParamsId defines parameters for GetFixture. +type GetFixtureParamsId string + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (GET /fixture/{id}) + GetFixture(ctx *echo.Context, id GetFixtureParamsId) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// GetFixture converts echo context to params. +func (w *ServerInterfaceWrapper) GetFixture(ctx *echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id GetFixtureParamsId + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: ctx.Request().URL.RawPath == ""}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetFixture(ctx, id) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo +} + +// RegisterHandlersOptions configures RegisterHandlersWithOptions. +type RegisterHandlersOptions struct { + // BaseURL is prepended to every registered path so the API can be served + // under a prefix. + BaseURL string + // OperationMiddlewares lets the caller attach per-operation middleware at + // registration time. The map key is the OpenAPI `operationId` value as it + // appears in the spec (the raw, un-normalized form). Operations that have + // no entry are registered with no extra middleware. A nil map disables + // per-operation middleware entirely. + OperationMiddlewares map[string][]echo.MiddlewareFunc +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{}) +} + +// RegisterHandlersWithBaseURL registers handlers and prepends BaseURL to the +// paths so the API can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{BaseURL: baseURL}) +} + +// RegisterHandlersWithOptions registers handlers using the supplied options, +// including any per-operation middleware. +func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options RegisterHandlersOptions) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(options.BaseURL+"/fixture/:id", wrapper.GetFixture, options.OperationMiddlewares["GetFixture"]...) + +} + +type GetFixtureRequestObject struct { + Id GetFixtureParamsId `json:"id"` +} + +type GetFixtureResponseObject interface { + VisitGetFixtureResponse(w http.ResponseWriter) error +} + +type GetFixture204Response struct { +} + +func (response GetFixture204Response) VisitGetFixtureResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + + // (GET /fixture/{id}) + GetFixture(ctx context.Context, request GetFixtureRequestObject) (GetFixtureResponseObject, error) +} + +type StrictHandlerFunc func(ctx *echo.Context, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetFixture operation middleware +func (sh *strictHandler) GetFixture(ctx *echo.Context, id GetFixtureParamsId) error { + var request GetFixtureRequestObject + + request.Id = id + + handler := func(ctx *echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetFixture(ctx.Request().Context(), request.(GetFixtureRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetFixture") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetFixtureResponseObject); ok { + return validResponse.VisitGetFixtureResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// Base64 encoded, compressed with deflate, json marshaled OpenAPI spec. +// Stored as a slice of fixed-width chunks rather than one concatenated +// const string: with thousands of chunks the chained `+` fold is several +// times slower for the Go compiler than parsing a slice literal. +var swaggerSpec = []string{ + "TJC9bsMwDIRfJbhZdZy2k7YuKTp3DDKoFhMTSCSVooMGht69kOz+TIRI6o73zRjiNcVAQTPsjEzDJKz3", + "92GkK7XWBzkheZl0bAttALu2YaD3VN+jakIpxYDDKdZVZb3USXSJb+7C3mmUDQelszjlGDYn/tJJCAY3", + "kswxwGLX9V2PYhATBZcYFk/druthkJyO7aTt+nE7sy+1cSatJSZalN88LF5J978GyYm7kpJk2MMMrlZV", + "DwbBtUDsYSD0ObGQh1WZyCxxXdUeYsgKi9X6YfcXPatwOKOUYxXIKYa8oHvsn2vxlAfhpEvAfZyCr6Qq", + "qx/e7aj/pA/HcizfAQAA//8=", +} + +// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, +// after base64-decoding and flate-decompressing the embedded blob. +func decodeSpec() ([]byte, error) { + encoded := strings.Join(swaggerSpec, "") + compressed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr := flate.NewReader(bytes.NewReader(compressed)) + var buf bytes.Buffer + if _, err := buf.ReadFrom(zr); err != nil { + return nil, fmt.Errorf("read flate: %w", err) + } + if err := zr.Close(); err != nil { + return nil, fmt.Errorf("close flate reader: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cache of the decoded OpenAPI spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSpec returns the OpenAPI specification corresponding to the generated +// code in this file. External references in the spec are resolved through +// PathToRawSpec; externally-referenced files must be embedded in their +// corresponding Go packages (via the import-mapping feature). URL-based +// external refs are not supported. +func GetSpec() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} + +// GetSpecJSON returns the raw JSON bytes of the embedded OpenAPI +// specification: decompressed but not unmarshaled. External references +// are not resolved here; the bytes are the spec exactly as embedded by +// codegen. The result is cached at package init time, so repeated calls +// are cheap. +func GetSpecJSON() ([]byte, error) { + return rawSpec() +} + +// GetSwagger returns the OpenAPI specification corresponding to the +// generated code in this file. +// +// Deprecated: GetSwagger predates kin-openapi renaming openapi3.Swagger +// to openapi3.T. Use [GetSpec] instead. This wrapper is retained for +// backwards compatibility. +func GetSwagger() (*openapi3.T, error) { + return GetSpec() +} diff --git a/oapivalidator/internal/testapi/server.yaml b/oapivalidator/internal/testapi/server.yaml new file mode 100644 index 0000000..0a288fc --- /dev/null +++ b/oapivalidator/internal/testapi/server.yaml @@ -0,0 +1,6 @@ +package: testapi +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/oapivalidator/middleware.go b/oapivalidator/middleware.go new file mode 100644 index 0000000..7c41a5d --- /dev/null +++ b/oapivalidator/middleware.go @@ -0,0 +1,207 @@ +package oapivalidator + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" + "github.com/labstack/echo/v5" +) + +//go:generate go tool mockgen -destination mocks/interfaces.gen.go -package mocks . Authenticator,FailureHandler + +// New constructs request validation middleware for document. +func New(document *openapi3.T, options ...Option) (echo.MiddlewareFunc, error) { + if document == nil { + return nil, errors.New("openapi document must not be nil") + } + if err := document.Validate(context.Background()); err != nil { + return nil, fmt.Errorf("validate OpenAPI document: %w", err) + } + + configuration := defaultConfig() + for index, option := range options { + if option == nil { + return nil, fmt.Errorf("option %d must not be nil", index) + } + if err := option.apply(&configuration); err != nil { + return nil, fmt.Errorf("apply option %d: %w", index, err) + } + } + if configuration.failureHandler == nil { + configuration.failureHandler = defaultFailureHandler{} + } + if configuration.authenticator == nil && documentRequiresAuthentication(document) { + return nil, errors.New("openapi document has operations with mandatory security but no authenticator is configured") + } + + routingDocument := *document + routingDocument.Servers = nil + if routingDocument.Paths == nil { + routingDocument.Paths = openapi3.NewPaths() + } + router, err := legacyrouter.NewRouter(&routingDocument) + if err != nil { + return nil, fmt.Errorf("construct OpenAPI router: %w", err) + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + return validate(c, next, router, configuration) + } + }, nil +} + +func validate(c *echo.Context, next echo.HandlerFunc, router routers.Router, configuration config) error { + validationRequest, ok := requestForValidation(c.Request(), configuration.baseURL) + if !ok { + failure := newFailure(FailureNotFound, "", nil, false, routers.ErrPathNotFound) + return handleFailure(c, configuration.failureHandler, failure) + } + + route, pathParameters, err := router.FindRoute(validationRequest) + if err != nil { + kind := FailureNotFound + allow := allowedMethods(router, validationRequest) + if isRouteError(err, routers.ErrMethodNotAllowed) || allow != "" { + kind = FailureMethodNotAllowed + c.Response().Header().Set("Allow", allow) + } + return handleFailure(c, configuration.failureHandler, newFailure(kind, "", nil, false, err)) + } + + validationOptions := &openapi3filter.Options{MultiError: true} + validationInput := &openapi3filter.RequestValidationInput{ + Request: validationRequest, + PathParams: pathParameters, + Route: route, + Options: validationOptions, + } + validationOptions.AuthenticationFunc = authenticationFunc(c, configuration.authenticator) + if err := openapi3filter.ValidateRequest(validationRequest.Context(), validationInput); err != nil { + synchronizeRequest(c, validationInput.Request) + failure := normalizeFailure(err, route.Operation.OperationID, configuration.maxReportedErrors) + return handleFailure(c, configuration.failureHandler, failure) + } + + synchronizeRequest(c, validationInput.Request) + return next(c) +} + +func requestForValidation(request *http.Request, baseURL string) (*http.Request, bool) { + validationRequest := request.Clone(request.Context()) + urlCopy := *request.URL + validationRequest.URL = &urlCopy + if baseURL == "" { + return validationRequest, true + } + path := validationRequest.URL.Path + if path != baseURL && !strings.HasPrefix(path, baseURL+"/") { + return nil, false + } + path = strings.TrimPrefix(path, baseURL) + if path == "" { + path = "/" + } + validationRequest.URL.Path = path + validationRequest.URL.RawPath = "" + return validationRequest, true +} + +func synchronizeRequest(c *echo.Context, validated *http.Request) { + if validated == nil { + return + } + current := c.Request().WithContext(validated.Context()) + current.Body = validated.Body + current.GetBody = validated.GetBody + current.ContentLength = validated.ContentLength + c.SetRequest(current) +} + +func authenticationFunc(c *echo.Context, authenticator Authenticator) openapi3filter.AuthenticationFunc { + return func(_ context.Context, input *openapi3filter.AuthenticationInput) error { + if authenticator == nil { + return &authenticationError{cause: ErrUnauthenticated, scheme: input.SecurityScheme} + } + currentContext := input.RequestValidationInput.Request.Context() + nextContext, err := authenticator.Authenticate(currentContext, AuthenticationInput{ + Request: input.RequestValidationInput.Request, + OperationID: input.RequestValidationInput.Route.Operation.OperationID, + SecuritySchemeName: input.SecuritySchemeName, + SecurityScheme: input.SecurityScheme, + Scopes: slices.Clone(input.Scopes), + }) + if err != nil { + return &authenticationError{cause: err, scheme: input.SecurityScheme} + } + if nextContext == nil { + return &authenticationError{cause: errNilAuthenticationContext, scheme: input.SecurityScheme} + } + updatedValidationRequest := input.RequestValidationInput.Request.WithContext(nextContext) + input.RequestValidationInput.Request = updatedValidationRequest + c.SetRequest(c.Request().WithContext(nextContext)) + return nil + } +} + +func documentRequiresAuthentication(document *openapi3.T) bool { + if document.Paths == nil { + return false + } + for _, pathItem := range document.Paths.Map() { + for _, operation := range pathItem.Operations() { + security := operation.Security + if security == nil { + security = &document.Security + } + if securityIsMandatory(security) { + return true + } + } + } + return false +} + +func handleFailure(c *echo.Context, handler FailureHandler, failure *Failure) error { + if failure.wwwAuthenticate != "" { + c.Response().Header().Set("WWW-Authenticate", failure.wwwAuthenticate) + } + return handler.Handle(c, failure) +} + +func securityIsMandatory(requirements *openapi3.SecurityRequirements) bool { + if requirements == nil || len(*requirements) == 0 { + return false + } + for _, requirement := range *requirements { + if len(requirement) == 0 { + return false + } + } + return true +} + +func isRouteError(actual, target error) bool { + return actual != nil && target != nil && actual.Error() == target.Error() +} + +func allowedMethods(router routers.Router, request *http.Request) string { + methods := []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions, http.MethodConnect, http.MethodTrace} + allowed := make([]string, 0, len(methods)) + for _, method := range methods { + candidate := request.Clone(request.Context()) + candidate.Method = method + if _, _, err := router.FindRoute(candidate); err == nil { + allowed = append(allowed, method) + } + } + return strings.Join(allowed, ", ") +} diff --git a/oapivalidator/middleware_test.go b/oapivalidator/middleware_test.go new file mode 100644 index 0000000..125af48 --- /dev/null +++ b/oapivalidator/middleware_test.go @@ -0,0 +1,307 @@ +package oapivalidator_test + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/devctllabs/go-libs/oapivalidator" + "github.com/devctllabs/go-libs/oapivalidator/mocks" +) + +func TestMiddlewareAcceptsValidOpenAPI31Request(t *testing.T) { + t.Parallel() + middleware, err := oapivalidator.New(loadDocument(t, publicDocument)) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/widgets/widget-1", "", "", nil) + + require.Equal(t, http.StatusNoContent, recorder.Code) +} + +func TestMiddlewareUsesOpenAPI31NullableTypeSemantics(t *testing.T) { + t.Parallel() + middleware, err := oapivalidator.New(loadDocument(t, publicDocument)) + require.NoError(t, err) + + nullResponse := serve(t, middleware, http.MethodPost, "/nullable", `null`, "application/json", nil) + stringResponse := serve(t, middleware, http.MethodPost, "/nullable", `"value"`, "application/json", nil) + invalidResponse := serve(t, middleware, http.MethodPost, "/nullable", `42`, "application/json", nil) + + require.Equal(t, http.StatusNoContent, nullResponse.Code) + require.Equal(t, http.StatusNoContent, stringResponse.Code) + require.Equal(t, http.StatusUnprocessableEntity, invalidResponse.Code) +} + +func TestMiddlewareIgnoresDocumentServers(t *testing.T) { + t.Parallel() + document := loadDocument(t, strings.Replace(publicDocument, "paths:", "servers:\n - url: https://example.com/service\npaths:", 1)) + middleware, err := oapivalidator.New(document) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/widgets/widget-1", "", "", nil) + + require.Equal(t, http.StatusNoContent, recorder.Code) +} + +func TestMiddlewareSupportsGeneratedBaseURL(t *testing.T) { + t.Parallel() + middleware, err := oapivalidator.New(loadDocument(t, publicDocument), oapivalidator.WithBaseURL("/api/v1")) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodGet, "/api/v1/widgets/widget-1", "", "", nil) + + require.Equal(t, http.StatusNoContent, recorder.Code) +} + +func TestMiddlewareWritesSafeRoutingProblems(t *testing.T) { + t.Parallel() + middleware, err := oapivalidator.New(loadDocument(t, publicDocument)) + require.NoError(t, err) + + t.Run("not found", func(t *testing.T) { + t.Parallel() + recorder := serve(t, middleware, http.MethodGet, "/missing", "", "", nil) + problem := decodeProblem(t, recorder) + require.Equal(t, http.StatusNotFound, recorder.Code) + require.Equal(t, "application/problem+json", recorder.Header().Get("Content-Type")) + require.Equal(t, oapivalidator.ProblemTypeNotFound, problem.Type) + }) + + t.Run("method not allowed", func(t *testing.T) { + t.Parallel() + recorder := serve(t, middleware, http.MethodDelete, "/widgets/widget-1", "", "", nil) + problem := decodeProblem(t, recorder) + require.Equal(t, http.StatusMethodNotAllowed, recorder.Code) + require.Equal(t, "GET", recorder.Header().Get("Allow")) + require.Equal(t, oapivalidator.ProblemTypeMethodNotAllowed, problem.Type) + }) +} + +func TestMiddlewareClassifiesAndNormalizesRequestFailures(t *testing.T) { + t.Parallel() + middleware, err := oapivalidator.New(loadDocument(t, publicDocument)) + require.NoError(t, err) + + tests := []struct { + name string + method string + target string + body string + contentType string + status int + kindType string + location oapivalidator.Location + }{ + { + name: "path schema", method: http.MethodGet, target: "/widgets/private-value", + status: http.StatusUnprocessableEntity, kindType: oapivalidator.ProblemTypeInvalidRequest, + location: oapivalidator.LocationPath, + }, + { + name: "malformed query parameter", method: http.MethodGet, target: "/widgets/widget-1?limit=not-an-integer", + status: http.StatusBadRequest, kindType: oapivalidator.ProblemTypeMalformedRequest, + location: oapivalidator.LocationQuery, + }, + { + name: "malformed json", method: http.MethodPost, target: "/widgets", body: `{"name":`, contentType: "application/json", + status: http.StatusBadRequest, kindType: oapivalidator.ProblemTypeMalformedRequest, + location: oapivalidator.LocationBody, + }, + { + name: "unsupported media type", method: http.MethodPost, target: "/widgets", body: `name=value`, contentType: "text/plain", + status: http.StatusUnsupportedMediaType, kindType: oapivalidator.ProblemTypeUnsupportedMediaType, + location: oapivalidator.LocationBody, + }, + { + name: "body schema", method: http.MethodPost, target: "/widgets", body: `{"name":"private-value","count":0}`, contentType: "application/json", + status: http.StatusUnprocessableEntity, kindType: oapivalidator.ProblemTypeInvalidRequest, + location: oapivalidator.LocationBody, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + recorder := serve(t, middleware, test.method, test.target, test.body, test.contentType, nil) + problem := decodeProblem(t, recorder) + require.Equal(t, test.status, recorder.Code) + require.Equal(t, test.kindType, problem.Type) + require.NotEmpty(t, problem.Errors) + require.Equal(t, test.location, problem.Errors[0].In) + require.NotContains(t, recorder.Body.String(), "private-value") + require.NotContains(t, recorder.Body.String(), "Schema:") + }) + } +} + +func TestMiddlewareCapsSortedDeduplicatedErrors(t *testing.T) { + t.Parallel() + middleware, err := oapivalidator.New( + loadDocument(t, publicDocument), + oapivalidator.WithMaxReportedErrors(1), + ) + require.NoError(t, err) + + recorder := serve(t, middleware, http.MethodPost, "/widgets", `{"name":"x","count":0}`, "application/json", nil) + problem := decodeProblem(t, recorder) + + require.Equal(t, http.StatusUnprocessableEntity, recorder.Code) + require.Len(t, problem.Errors, 1) + require.True(t, problem.Truncated, "%+v", problem) +} + +func TestNewRejectsInvalidConfiguration(t *testing.T) { + t.Parallel() + tests := []struct { + name string + document *openapi3.T + option oapivalidator.Option + }{ + {name: "nil document"}, + {name: "zero max errors", document: loadDocument(t, publicDocument), option: oapivalidator.WithMaxReportedErrors(0)}, + {name: "relative base URL", document: loadDocument(t, publicDocument), option: oapivalidator.WithBaseURL("api")}, + {name: "base URL query", document: loadDocument(t, publicDocument), option: oapivalidator.WithBaseURL("/api?debug=true")}, + {name: "base URL trailing slash", document: loadDocument(t, publicDocument), option: oapivalidator.WithBaseURL("/api/")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + options := []oapivalidator.Option{} + if test.option != nil { + options = append(options, test.option) + } + _, err := oapivalidator.New(test.document, options...) + require.Error(t, err) + }) + } +} + +func TestMiddlewareDelegatesNormalizedFailure(t *testing.T) { + t.Parallel() + controller := gomock.NewController(t) + handler := mocks.NewMockFailureHandler(controller) + wantErr := errors.New("handled") + handler.EXPECT().Handle(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ *echo.Context, failure *oapivalidator.Failure) error { + require.Equal(t, oapivalidator.FailureNotFound, failure.Kind) + require.Error(t, failure.Cause) + return wantErr + }, + ) + middleware, err := oapivalidator.New(loadDocument(t, publicDocument), oapivalidator.WithFailureHandler(handler)) + require.NoError(t, err) + + actualErr := invoke(t, middleware, http.MethodGet, "/missing", "", "", nil) + + require.ErrorIs(t, actualErr, wantErr) +} + +func loadDocument(t *testing.T, source string) *openapi3.T { + t.Helper() + document, err := openapi3.NewLoader().LoadFromData([]byte(source)) + require.NoError(t, err) + return document +} + +func serve(t *testing.T, middleware echo.MiddlewareFunc, method, target, body, contentType string, handler echo.HandlerFunc) *httptest.ResponseRecorder { + t.Helper() + recorder, err := invokeWithRecorder(t, middleware, method, target, body, contentType, handler) + require.NoError(t, err) + return recorder +} + +func invoke(t *testing.T, middleware echo.MiddlewareFunc, method, target, body, contentType string, handler echo.HandlerFunc) error { + t.Helper() + _, err := invokeWithRecorder(t, middleware, method, target, body, contentType, handler) + return err +} + +func invokeWithRecorder(t *testing.T, middleware echo.MiddlewareFunc, method, target, body, contentType string, handler echo.HandlerFunc) (*httptest.ResponseRecorder, error) { + t.Helper() + e := echo.New() + request := httptest.NewRequest(method, target, strings.NewReader(body)) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + recorder := httptest.NewRecorder() + context := e.NewContext(request, recorder) + if handler == nil { + handler = func(c *echo.Context) error { return c.NoContent(http.StatusNoContent) } + } + return recorder, middleware(handler)(context) +} + +func decodeProblem(t *testing.T, recorder *httptest.ResponseRecorder) oapivalidator.Problem { + t.Helper() + var problem oapivalidator.Problem + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &problem)) + return problem +} + +const publicDocument = ` +openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +paths: + /widgets/{id}: + get: + operationId: getWidget + parameters: + - name: id + in: path + required: true + schema: + type: string + const: widget-1 + - name: limit + in: query + schema: + type: integer + minimum: 1 + responses: + "204": + description: Found + /widgets: + post: + operationId: createWidget + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, count] + properties: + name: + type: string + minLength: 3 + count: + type: integer + minimum: 1 + responses: + "204": + description: Created + /nullable: + post: + operationId: acceptNullable + requestBody: + required: true + content: + application/json: + schema: + type: [string, "null"] + responses: + "204": + description: Accepted +` diff --git a/oapivalidator/mocks/interfaces.gen.go b/oapivalidator/mocks/interfaces.gen.go new file mode 100644 index 0000000..66496d7 --- /dev/null +++ b/oapivalidator/mocks/interfaces.gen.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/oapivalidator (interfaces: Authenticator,FailureHandler) +// +// Generated by this command: +// +// mockgen -destination mocks/interfaces.gen.go -package mocks . Authenticator,FailureHandler +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + oapivalidator "github.com/devctllabs/go-libs/oapivalidator" + echo "github.com/labstack/echo/v5" + gomock "go.uber.org/mock/gomock" +) + +// MockAuthenticator is a mock of Authenticator interface. +type MockAuthenticator struct { + ctrl *gomock.Controller + recorder *MockAuthenticatorMockRecorder + isgomock struct{} +} + +// MockAuthenticatorMockRecorder is the mock recorder for MockAuthenticator. +type MockAuthenticatorMockRecorder struct { + mock *MockAuthenticator +} + +// NewMockAuthenticator creates a new mock instance. +func NewMockAuthenticator(ctrl *gomock.Controller) *MockAuthenticator { + mock := &MockAuthenticator{ctrl: ctrl} + mock.recorder = &MockAuthenticatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAuthenticator) EXPECT() *MockAuthenticatorMockRecorder { + return m.recorder +} + +// Authenticate mocks base method. +func (m *MockAuthenticator) Authenticate(ctx context.Context, input oapivalidator.AuthenticationInput) (context.Context, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Authenticate", ctx, input) + ret0, _ := ret[0].(context.Context) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Authenticate indicates an expected call of Authenticate. +func (mr *MockAuthenticatorMockRecorder) Authenticate(ctx, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authenticate", reflect.TypeOf((*MockAuthenticator)(nil).Authenticate), ctx, input) +} + +// MockFailureHandler is a mock of FailureHandler interface. +type MockFailureHandler struct { + ctrl *gomock.Controller + recorder *MockFailureHandlerMockRecorder + isgomock struct{} +} + +// MockFailureHandlerMockRecorder is the mock recorder for MockFailureHandler. +type MockFailureHandlerMockRecorder struct { + mock *MockFailureHandler +} + +// NewMockFailureHandler creates a new mock instance. +func NewMockFailureHandler(ctrl *gomock.Controller) *MockFailureHandler { + mock := &MockFailureHandler{ctrl: ctrl} + mock.recorder = &MockFailureHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFailureHandler) EXPECT() *MockFailureHandlerMockRecorder { + return m.recorder +} + +// Handle mocks base method. +func (m *MockFailureHandler) Handle(c *echo.Context, failure *oapivalidator.Failure) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Handle", c, failure) + ret0, _ := ret[0].(error) + return ret0 +} + +// Handle indicates an expected call of Handle. +func (mr *MockFailureHandlerMockRecorder) Handle(c, failure any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockFailureHandler)(nil).Handle), c, failure) +} diff --git a/oapivalidator/normalize.go b/oapivalidator/normalize.go new file mode 100644 index 0000000..eaf724e --- /dev/null +++ b/oapivalidator/normalize.go @@ -0,0 +1,341 @@ +package oapivalidator + +import ( + "encoding/json" + "errors" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" +) + +func newFailure(kind FailureKind, operationID string, fieldErrors []FieldError, truncated bool, cause error) *Failure { + return &Failure{ + Kind: kind, + Status: statusForKind(kind), + OperationID: operationID, + Errors: fieldErrors, + Truncated: truncated, + Cause: cause, + } +} + +func normalizeFailure(cause error, operationID string, limit int) *Failure { + if kind, challenge, ok := authenticationFailure(cause); ok { + failure := newFailure(kind, operationID, nil, false, cause) + failure.wwwAuthenticate = challenge + return failure + } + + kind := validationFailureKind(cause) + fieldErrors := make([]FieldError, 0) + appendFieldErrors(cause, fieldContext{}, &fieldErrors) + fieldErrors = sortAndDedupe(fieldErrors) + truncated := len(fieldErrors) > limit + if truncated { + fieldErrors = fieldErrors[:limit] + } + return newFailure(kind, operationID, fieldErrors, truncated, cause) +} + +func authenticationFailure(cause error) (FailureKind, string, bool) { + authenticationErrors := make([]*authenticationError, 0) + walkErrors(cause, func(err error) { + if authenticationFailure, ok := err.(*authenticationError); ok { + authenticationErrors = append(authenticationErrors, authenticationFailure) + } + }) + if len(authenticationErrors) == 0 { + return "", "", false + } + + for _, failure := range authenticationErrors { + if !errors.Is(failure.cause, ErrUnauthenticated) && !errors.Is(failure.cause, ErrForbidden) { + return FailureInternal, "", true + } + } + for _, failure := range authenticationErrors { + if errors.Is(failure.cause, ErrForbidden) { + return FailureForbidden, "", true + } + } + for _, failure := range authenticationErrors { + if errors.Is(failure.cause, ErrUnauthenticated) { + return FailureUnauthenticated, authenticationChallenge(failure.scheme), true + } + } + return FailureInternal, "", true +} + +func authenticationChallenge(scheme *openapi3.SecurityScheme) string { + if scheme == nil { + return "" + } + switch strings.ToLower(scheme.Type) { + case "oauth2", "openidconnect": + return "Bearer" + case "http": + switch strings.ToLower(scheme.Scheme) { + case "bearer": + return "Bearer" + case "basic": + return "Basic" + } + } + return "" +} + +func validationFailureKind(cause error) FailureKind { + hasRequestFailure := false + kind := FailureInvalidRequest + walkErrors(cause, func(err error) { + requestFailure, ok := err.(*openapi3filter.RequestError) + if !ok { + return + } + hasRequestFailure = true + if isUnsupportedMediaType(requestFailure) { + kind = FailureUnsupportedMediaType + return + } + if kind != FailureUnsupportedMediaType && isMalformed(requestFailure) { + kind = FailureMalformedRequest + } + }) + if !hasRequestFailure { + return FailureInternal + } + return kind +} + +func isUnsupportedMediaType(failure *openapi3filter.RequestError) bool { + return strings.HasPrefix(failure.Reason, "header Content-Type has unexpected value") || + strings.Contains(errorText(failure.Err), "unsupported content type") +} + +func isMalformed(failure *openapi3filter.RequestError) bool { + if failure.Reason == "failed to decode request body" || failure.Reason == "reading failed" { + return true + } + var syntaxError *json.SyntaxError + if errors.As(failure.Err, &syntaxError) { + return true + } + if failure.Parameter == nil || failure.Err == nil || + errors.Is(failure.Err, openapi3filter.ErrInvalidRequired) || + errors.Is(failure.Err, openapi3filter.ErrInvalidEmptyValue) { + return false + } + var schemaError *openapi3.SchemaError + return !errors.As(failure.Err, &schemaError) +} + +func errorText(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +type fieldContext struct { + location Location + parameter string + body bool +} + +const requiredFieldCode = "required" + +func appendFieldErrors(err error, inherited fieldContext, destination *[]FieldError) { + if err == nil { + return + } + switch failure := err.(type) { + case *authenticationError: + return + case openapi3.MultiError: + for _, child := range failure { + appendFieldErrors(child, inherited, destination) + } + return + case *openapi3filter.SecurityRequirementsError: + return + case *openapi3filter.RequestError: + appendRequestFieldErrors(failure, inherited, destination) + case *openapi3.SchemaError: + appendSchemaFieldError(failure, inherited, destination) + default: + appendWrappedFieldErrors(err, inherited, destination) + } +} + +func appendRequestFieldErrors(failure *openapi3filter.RequestError, inherited fieldContext, destination *[]FieldError) { + context := inherited + if failure.Parameter != nil { + context.location = parameterLocation(failure.Parameter.In) + context.parameter = failure.Parameter.Name + context.body = false + } + if failure.RequestBody != nil { + context.location = LocationBody + context.parameter = "" + context.body = true + } + if failure.Err != nil && hasStructuredChildren(failure.Err) { + appendFieldErrors(failure.Err, context, destination) + return + } + *destination = append(*destination, requestFieldError(failure, context)) +} + +func appendSchemaFieldError(failure *openapi3.SchemaError, inherited fieldContext, destination *[]FieldError) { + if failure.Origin != nil { + appendFieldErrors(failure.Origin, inherited, destination) + return + } + code := "invalid" + detail := "The value does not satisfy the schema." + if failure.SchemaField == requiredFieldCode { + code = requiredFieldCode + detail = "A required value is missing." + } + fieldError := FieldError{Code: code, Detail: detail, In: inherited.location, Parameter: inherited.parameter} + if inherited.body { + fieldError.Pointer = jsonPointer(failure.JSONPointer()) + if fieldError.Pointer == "" { + fieldError.Pointer = schemaPointerFromReason(failure.Reason) + } + } + *destination = append(*destination, fieldError) +} + +func appendWrappedFieldErrors(err error, inherited fieldContext, destination *[]FieldError) { + type multiUnwrapper interface{ Unwrap() []error } + if unwrapped, ok := err.(multiUnwrapper); ok { + for _, child := range unwrapped.Unwrap() { + appendFieldErrors(child, inherited, destination) + } + return + } + if child := errors.Unwrap(err); child != nil { + appendFieldErrors(child, inherited, destination) + } +} + +func schemaPointerFromReason(reason string) string { + const marker = `error at "` + start := strings.Index(reason, marker) + if start < 0 { + return "" + } + start += len(marker) + end := strings.IndexByte(reason[start:], '"') + if end < 0 { + return "" + } + pointer := reason[start : start+end] + if !strings.HasPrefix(pointer, "/") { + return "" + } + return pointer +} + +func hasStructuredChildren(err error) bool { + if err == nil { + return false + } + switch err.(type) { + case openapi3.MultiError, *openapi3.SchemaError: + return true + default: + return false + } +} + +func requestFieldError(failure *openapi3filter.RequestError, context fieldContext) FieldError { + code := "invalid" + detail := "The value is invalid." + switch { + case isUnsupportedMediaType(failure): + code = "unsupported_media_type" + detail = "The content type is not supported." + case isMalformed(failure): + code = "malformed" + detail = "The value could not be decoded." + case errors.Is(failure.Err, openapi3filter.ErrInvalidRequired): + code = "required" + detail = "A required value is missing." + } + return FieldError{Code: code, Detail: detail, In: context.location, Parameter: context.parameter} +} + +func parameterLocation(in string) Location { + switch in { + case openapi3.ParameterInPath: + return LocationPath + case openapi3.ParameterInQuery: + return LocationQuery + case openapi3.ParameterInHeader: + return LocationHeader + case openapi3.ParameterInCookie: + return LocationCookie + default: + return "" + } +} + +func jsonPointer(parts []string) string { + if len(parts) == 0 { + return "" + } + escaped := make([]string, len(parts)) + for index, part := range parts { + part = strings.ReplaceAll(part, "~", "~0") + escaped[index] = strings.ReplaceAll(part, "/", "~1") + } + return "/" + strings.Join(escaped, "/") +} + +func sortAndDedupe(fieldErrors []FieldError) []FieldError { + sort.SliceStable(fieldErrors, func(left, right int) bool { + return fieldErrorKey(fieldErrors[left]) < fieldErrorKey(fieldErrors[right]) + }) + result := fieldErrors[:0] + var previous string + for index, fieldError := range fieldErrors { + key := fieldErrorKey(fieldError) + if index > 0 && key == previous { + continue + } + result = append(result, fieldError) + previous = key + } + return result +} + +func fieldErrorKey(fieldError FieldError) string { + return strings.Join([]string{string(fieldError.In), fieldError.Pointer, fieldError.Parameter, fieldError.Code, fieldError.Detail}, "\x00") +} + +func walkErrors(err error, visit func(error)) { + if err == nil { + return + } + visit(err) + if multiError, ok := err.(openapi3.MultiError); ok { + for _, child := range multiError { + walkErrors(child, visit) + } + return + } + type multiUnwrapper interface{ Unwrap() []error } + if unwrapped, ok := err.(multiUnwrapper); ok { + for _, child := range unwrapped.Unwrap() { + walkErrors(child, visit) + } + return + } + if child := errors.Unwrap(err); child != nil { + walkErrors(child, visit) + } +} diff --git a/oapivalidator/options.go b/oapivalidator/options.go new file mode 100644 index 0000000..c6e6e06 --- /dev/null +++ b/oapivalidator/options.go @@ -0,0 +1,93 @@ +package oapivalidator + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// Option configures request validation middleware. +type Option interface { + apply(config *config) error +} + +type optionFunc func(config *config) error + +func (fn optionFunc) apply(config *config) error { return fn(config) } + +type config struct { + authenticator Authenticator + failureHandler FailureHandler + baseURL string + maxReportedErrors int +} + +const defaultMaxReportedErrors = 20 + +func defaultConfig() config { return config{maxReportedErrors: defaultMaxReportedErrors} } + +// WithAuthenticator configures OpenAPI security-scheme authentication. +func WithAuthenticator(authenticator Authenticator) Option { + return optionFunc(func(config *config) error { + if authenticator == nil { + return errors.New("authenticator must not be nil") + } + config.authenticator = authenticator + return nil + }) +} + +// WithFailureHandler replaces the default RFC 9457 problem writer. +func WithFailureHandler(handler FailureHandler) Option { + return optionFunc(func(config *config) error { + if handler == nil { + return errors.New("failure handler must not be nil") + } + config.failureHandler = handler + return nil + }) +} + +// WithBaseURL configures the path prefix used when generated handlers are +// registered with oapi-codegen RegisterHandlersOptions.BaseURL. +func WithBaseURL(baseURL string) Option { + return optionFunc(func(config *config) error { + normalized, err := normalizeBaseURL(baseURL) + if err != nil { + return err + } + config.baseURL = normalized + return nil + }) +} + +// WithMaxReportedErrors limits the normalized validation errors returned to a +// client. Validation itself still examines the complete request. +func WithMaxReportedErrors(maxErrors int) Option { + return optionFunc(func(config *config) error { + if maxErrors <= 0 { + return fmt.Errorf("max reported errors must be positive: %d", maxErrors) + } + config.maxReportedErrors = maxErrors + return nil + }) +} + +func normalizeBaseURL(baseURL string) (string, error) { + if baseURL == "" { + return "", nil + } + if strings.HasSuffix(baseURL, "/") { + return "", fmt.Errorf("base URL must not have a trailing slash: %q", baseURL) + } + parsed, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("parse base URL: %w", err) + } + if parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || + !strings.HasPrefix(parsed.Path, "/") || parsed.Path != baseURL { + return "", fmt.Errorf("base URL must be an absolute path without query or fragment: %q", baseURL) + } + return parsed.Path, nil +} diff --git a/postgresdb/config_test.go b/postgresdb/config_test.go new file mode 100644 index 0000000..49f6844 --- /dev/null +++ b/postgresdb/config_test.go @@ -0,0 +1,40 @@ +package postgresdb + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +func TestOpenAppliesTypedPoolOverridesWithoutConnecting(t *testing.T) { + t.Parallel() + db, err := Open(context.Background(), Config{ + Writer: EndpointConfig{ + DSN: "postgres://user:password@127.0.0.1:1/app?pool_max_conns=9&default_query_exec_mode=cache_statement", + Pool: PoolConfig{ + MaxConnections: 17, + MinIdleConnections: 2, + MaxConnectionLifetime: 45 * time.Minute, + MaxConnectionLifetimeJitter: 3 * time.Minute, + MaxConnectionIdleTime: 5 * time.Minute, + HealthCheckPeriod: 20 * time.Second, + }, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + poolConfig := db.writerPool.Config() + require.Equal(t, int32(17), poolConfig.MaxConns) + require.Equal(t, int32(2), poolConfig.MinIdleConns) + require.Equal(t, 45*time.Minute, poolConfig.MaxConnLifetime) + require.Equal(t, 3*time.Minute, poolConfig.MaxConnLifetimeJitter) + require.Equal(t, 5*time.Minute, poolConfig.MaxConnIdleTime) + require.Equal(t, 20*time.Second, poolConfig.HealthCheckPeriod) + require.Equal(t, pgx.QueryExecModeExec, poolConfig.ConnConfig.DefaultQueryExecMode) + require.Same(t, db.writerPool, db.readerPool) + require.NotSame(t, db.Writer(), db.Reader()) +} diff --git a/postgresdb/doc.go b/postgresdb/doc.go new file mode 100644 index 0000000..31a000f --- /dev/null +++ b/postgresdb/doc.go @@ -0,0 +1,10 @@ +// Package postgresdb owns instrumented pgx pools for PostgreSQL reader and +// writer endpoints. +// +// A reader DSN may point at a read-only replica; when omitted, the reader and +// writer endpoints share one physical pool. Endpoints route calls through native +// pgx transactions stored in context and are directly compatible with pgx +// scanners such as scany. The driver uses the extended query protocol without +// a prepared-statement cache so it remains safe behind PgBouncer transaction +// pooling. +package postgresdb diff --git a/postgresdb/endpoint.go b/postgresdb/endpoint.go new file mode 100644 index 0000000..1b71b3d --- /dev/null +++ b/postgresdb/endpoint.go @@ -0,0 +1,56 @@ +package postgresdb + +import ( + "context" + + "github.com/devctllabs/go-libs/txmanager" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Endpoint executes PostgreSQL queries against a role-bound pool or the active transaction in ctx. +type Endpoint struct { + pool *pgxpool.Pool + coordinator *txmanager.Coordinator[pgx.Tx] + manager txmanager.Manager +} + +// Exec executes query using the active transaction when ctx carries one. +func (e *Endpoint) Exec(ctx context.Context, query string, args ...any) (pgconn.CommandTag, error) { + if tx, ok := e.coordinator.Current(ctx); ok { + return tx.Exec(ctx, query, args...) + } + return e.pool.Exec(ctx, query, args...) +} + +// Query queries rows using the active transaction when ctx carries one. +func (e *Endpoint) Query(ctx context.Context, query string, args ...any) (pgx.Rows, error) { + if tx, ok := e.coordinator.Current(ctx); ok { + return tx.Query(ctx, query, args...) + } + return e.pool.Query(ctx, query, args...) +} + +// QueryRow queries one row using the active transaction when ctx carries one. +func (e *Endpoint) QueryRow(ctx context.Context, query string, args ...any) pgx.Row { + if tx, ok := e.coordinator.Current(ctx); ok { + return tx.QueryRow(ctx, query, args...) + } + return e.pool.QueryRow(ctx, query, args...) +} + +// WithinTx executes fn inside a transaction bound to this endpoint's role. +func (e *Endpoint) WithinTx( + ctx context.Context, + fn func(ctx context.Context) error, + options ...txmanager.Option, +) error { + return e.manager.WithinTx(ctx, fn, options...) +} + +// Check verifies that this endpoint can execute a bounded query. +func (e *Endpoint) Check(ctx context.Context) error { + var one int + return e.pool.QueryRow(ctx, `SELECT 1`).Scan(&one) +} diff --git a/postgresdb/example_test.go b/postgresdb/example_test.go new file mode 100644 index 0000000..15c2fbd --- /dev/null +++ b/postgresdb/example_test.go @@ -0,0 +1,36 @@ +package postgresdb_test + +import ( + "context" + "log" + + "github.com/devctllabs/go-libs/postgresdb" + "github.com/georgysavva/scany/v2/pgxscan" +) + +type user struct { + ID int64 `db:"id"` + Name string `db:"name"` +} + +func ExampleOpen() { + ctx := context.Background() + db, err := postgresdb.Open(ctx, postgresdb.Config{ + Writer: postgresdb.EndpointConfig{DSN: "postgres://app:password@postgres/app"}, + Reader: &postgresdb.EndpointConfig{DSN: "postgres://app:password@postgres-replica/app"}, + }) + if err != nil { + log.Fatal(err) + } + defer func() { + if err := db.Close(); err != nil { + log.Printf("close database: %v", err) + } + }() + + var users []user + if err := pgxscan.Select(ctx, db.Reader(), &users, `SELECT id, name FROM users`); err != nil { + log.Print(err) + return + } +} diff --git a/postgresdb/go.mod b/postgresdb/go.mod new file mode 100644 index 0000000..58caafa --- /dev/null +++ b/postgresdb/go.mod @@ -0,0 +1,71 @@ +module github.com/devctllabs/go-libs/postgresdb + +go 1.25.0 + +require ( + github.com/devctllabs/go-libs/txmanager v0.1.0 + github.com/exaring/otelpgx v0.11.1 + github.com/georgysavva/scany/v2 v2.1.4 + github.com/jackc/pgx/v5 v5.10.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/postgresdb/go.sum b/postgresdb/go.sum new file mode 100644 index 0000000..20e0ec2 --- /dev/null +++ b/postgresdb/go.sum @@ -0,0 +1,172 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/exaring/otelpgx v0.11.1 h1:pE79fIg/qh/Lpu00kvswFC5dKfqyJJhMJ4Y4N3w5Lj4= +github.com/exaring/otelpgx v0.11.1/go.mod h1:3OojrUKhhy3lTbYIMBijP3YjMey/jo14eHAW5cXcUdk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/postgresdb/integration_test.go b/postgresdb/integration_test.go new file mode 100644 index 0000000..2a0a5dd --- /dev/null +++ b/postgresdb/integration_test.go @@ -0,0 +1,227 @@ +//go:build integration + +package postgresdb_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/devctllabs/go-libs/postgresdb" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestPostgresEndpointAndTransactionBehavior(t *testing.T) { + ctx := context.Background() + container, err := tcpostgres.Run( + ctx, + "postgres:17.5-alpine", + tcpostgres.WithDatabase("app"), + tcpostgres.WithUsername("app"), + tcpostgres.WithPassword("password"), + tcpostgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testcontainers.TerminateContainer(container)) }) + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + db, err := postgresdb.Open(ctx, postgresdb.Config{ + Writer: postgresdb.EndpointConfig{DSN: dsn}, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + require.NoError(t, db.Writer().Check(ctx)) + require.NoError(t, db.Reader().Check(ctx)) + _, err = db.Writer().Exec(ctx, `CREATE TABLE entries (value text NOT NULL)`) + require.NoError(t, err) + + callbackErr := errors.New("rollback requested") + err = db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + _, insertErr := db.Writer().Exec(txCtx, `INSERT INTO entries VALUES ($1)`, "pending") + require.NoError(t, insertErr) + var count int + queryErr := db.Reader().QueryRow(txCtx, `SELECT COUNT(*) FROM entries`).Scan(&count) + require.NoError(t, queryErr) + require.Equal(t, 1, count) + return callbackErr + }) + require.ErrorIs(t, err, callbackErr) + + var count int + err = db.Reader().QueryRow(ctx, `SELECT COUNT(*) FROM entries`).Scan(&count) + require.NoError(t, err) + require.Zero(t, count) + require.Same(t, db.Reader(), db.TxManagers().Reader()) + require.Same(t, db.Writer(), db.TxManagers().Writer()) +} + +func TestPgBouncerTransactionPooling(t *testing.T) { + ctx := context.Background() + dsn := startPgBouncer(t, ctx) + db, err := postgresdb.Open(ctx, postgresdb.Config{ + Writer: postgresdb.EndpointConfig{DSN: dsn}, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + require.NoError(t, db.Writer().Check(ctx)) + _, err = db.Writer().Exec(ctx, `CREATE TABLE values_table (value integer NOT NULL)`) + require.NoError(t, err) + + for value := 1; value <= 3; value++ { + var selected int + err = db.Reader().QueryRow(ctx, `SELECT $1::integer`, value).Scan(&selected) + require.NoError(t, err) + require.Equal(t, value, selected) + } + err = db.Writer().WithinTx(ctx, func(txCtx context.Context) error { + _, insertErr := db.Writer().Exec(txCtx, `INSERT INTO values_table VALUES ($1)`, 42) + return insertErr + }) + require.NoError(t, err) + var count int + err = db.Reader().QueryRow(ctx, `SELECT COUNT(*) FROM values_table`).Scan(&count) + require.NoError(t, err) + require.Equal(t, 1, count) +} + +func TestPostgresTelemetryUsesExplicitProviderAndProtectsQueryData(t *testing.T) { + ctx := context.Background() + dsn := startPostgres(t, ctx) + globalRecorder := tracetest.NewSpanRecorder() + globalProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(globalRecorder)) + previousProvider := otel.GetTracerProvider() + otel.SetTracerProvider(globalProvider) + t.Cleanup(func() { + otel.SetTracerProvider(previousProvider) + require.NoError(t, globalProvider.Shutdown(context.Background())) + }) + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + db, err := postgresdb.Open(ctx, postgresdb.Config{ + Writer: postgresdb.EndpointConfig{DSN: dsn}, + Telemetry: postgresdb.Telemetry{ + TracerProvider: provider, + }, + }) + require.NoError(t, err) + var value string + queryCtx, parentSpan := provider.Tracer("postgresdb-integration-test").Start(ctx, "request") + err = db.Writer().QueryRow(queryCtx, `SELECT $1::text`, "sensitive-argument").Scan(&value) + require.NoError(t, err) + parentSpan.End() + require.NoError(t, db.Close()) + serialized := serializePostgresSpans(recorder.Ended()) + require.NotEmpty(t, recorder.Ended()) + require.Empty(t, globalRecorder.Ended()) + require.NotContains(t, serialized, "SELECT $1::text") + require.NotContains(t, serialized, "sensitive-argument") + + visibleRecorder := tracetest.NewSpanRecorder() + visibleProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(visibleRecorder)) + t.Cleanup(func() { require.NoError(t, visibleProvider.Shutdown(context.Background())) }) + visibleDB, err := postgresdb.Open(ctx, postgresdb.Config{ + Writer: postgresdb.EndpointConfig{DSN: dsn}, + Telemetry: postgresdb.Telemetry{ + TracerProvider: visibleProvider, + IncludeQueryText: true, + }, + }) + require.NoError(t, err) + queryCtx, parentSpan = visibleProvider.Tracer("postgresdb-integration-test").Start(ctx, "request") + err = visibleDB.Writer().QueryRow( + queryCtx, + `SELECT $1::text AS visible_statement`, + "sensitive-argument", + ).Scan(&value) + require.NoError(t, err) + parentSpan.End() + require.NoError(t, visibleDB.Close()) + serialized = serializePostgresSpans(visibleRecorder.Ended()) + require.Contains(t, serialized, "SELECT $1::text AS visible_statement") + require.NotContains(t, serialized, "sensitive-argument") +} + +func startPostgres(t *testing.T, ctx context.Context) string { + t.Helper() + container, err := tcpostgres.Run( + ctx, + "postgres:17.5-alpine", + tcpostgres.WithDatabase("app"), + tcpostgres.WithUsername("app"), + tcpostgres.WithPassword("password"), + tcpostgres.BasicWaitStrategies(), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testcontainers.TerminateContainer(container)) }) + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + return dsn +} + +func serializePostgresSpans(spans []sdktrace.ReadOnlySpan) string { + var serialized strings.Builder + for _, span := range spans { + _, _ = fmt.Fprintln(&serialized, span.Name()) + for _, attribute := range span.Attributes() { + _, _ = fmt.Fprintf(&serialized, "%s=%v\n", attribute.Key, attribute.Value.AsInterface()) + } + } + return serialized.String() +} + +func startPgBouncer(t *testing.T, ctx context.Context) string { + t.Helper() + dockerNetwork, err := network.New(ctx) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, dockerNetwork.Remove(context.Background())) }) + postgresContainer, err := tcpostgres.Run( + ctx, + "postgres:17.5-alpine", + tcpostgres.WithDatabase("app"), + tcpostgres.WithUsername("app"), + tcpostgres.WithPassword("password"), + tcpostgres.BasicWaitStrategies(), + network.WithNetworkName([]string{"postgres"}, dockerNetwork.Name), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testcontainers.TerminateContainer(postgresContainer)) }) + pgBouncer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "edoburu/pgbouncer:v1.24.1-p0", + ExposedPorts: []string{"5432/tcp"}, + Networks: []string{dockerNetwork.Name}, + NetworkAliases: map[string][]string{dockerNetwork.Name: {"pgbouncer"}}, + Env: map[string]string{ + "DB_HOST": "postgres", + "DB_PORT": "5432", + "DB_USER": "app", + "DB_PASSWORD": "password", + "DB_NAME": "app", + "POOL_MODE": "transaction", + "MAX_CLIENT_CONN": "100", + "DEFAULT_POOL_SIZE": "10", + "AUTH_TYPE": "scram-sha-256", + }, + WaitingFor: wait.ForListeningPort("5432/tcp"), + }, + Started: true, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testcontainers.TerminateContainer(pgBouncer)) }) + host, err := pgBouncer.Host(ctx) + require.NoError(t, err) + port, err := pgBouncer.MappedPort(ctx, "5432/tcp") + require.NoError(t, err) + return fmt.Sprintf("postgres://app:password@%s:%s/app?sslmode=disable", host, port.Port()) +} diff --git a/postgresdb/postgresdb.go b/postgresdb/postgresdb.go new file mode 100644 index 0000000..528d724 --- /dev/null +++ b/postgresdb/postgresdb.go @@ -0,0 +1,186 @@ +package postgresdb + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/devctllabs/go-libs/txmanager" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Config defines one PostgreSQL database instance. +type Config struct { + Writer EndpointConfig + Reader *EndpointConfig + Telemetry Telemetry +} + +// EndpointConfig configures one PostgreSQL pool. +type EndpointConfig struct { + DSN string + Pool PoolConfig +} + +// PoolConfig overrides a stable subset of pgx pool settings when fields are positive. +type PoolConfig struct { + MaxConnections int32 + MinIdleConnections int32 + MaxConnectionLifetime time.Duration + MaxConnectionLifetimeJitter time.Duration + MaxConnectionIdleTime time.Duration + HealthCheckPeriod time.Duration +} + +// DB owns PostgreSQL reader and writer resources. +type DB struct { + reader *Endpoint + writer *Endpoint + managers txmanager.Managers + readerPool *pgxpool.Pool + writerPool *pgxpool.Pool + closeOnce sync.Once +} + +// Open constructs a PostgreSQL database instance without requiring network availability. +func Open(ctx context.Context, cfg Config) (*DB, error) { + if ctx == nil { + return nil, errors.New("postgresdb: context must not be nil") + } + telemetry := newTelemetryConfig(cfg.Telemetry) + writerPool, err := openPool(ctx, cfg.Writer, telemetry, "writer") + if err != nil { + return nil, err + } + readerPool := writerPool + if cfg.Reader != nil { + readerPool, err = openPool(ctx, *cfg.Reader, telemetry, "reader") + if err != nil { + writerPool.Close() + return nil, err + } + } + return buildDB(readerPool, writerPool) +} + +func openPool(ctx context.Context, endpoint EndpointConfig, telemetry telemetryConfig, role string) (*pgxpool.Pool, error) { + if strings.TrimSpace(endpoint.DSN) == "" { + return nil, fmt.Errorf("postgresdb: %s DSN must not be blank", role) + } + config, err := parsePoolConfig(endpoint, telemetry, role) + if err != nil { + return nil, fmt.Errorf("postgresdb: %s config: %w", role, err) + } + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("postgresdb: open %s pool: %w", role, err) + } + if err := telemetry.recordPoolStats(pool, role); err != nil { + pool.Close() + return nil, fmt.Errorf("postgresdb: instrument %s pool: %w", role, err) + } + return pool, nil +} + +func buildDB(readerPool, writerPool *pgxpool.Pool) (*DB, error) { + backend := &transactionBackend{reader: readerPool, writer: writerPool} + coordinator, err := txmanager.NewCoordinator[pgx.Tx](backend) + if err != nil { + closePools(readerPool, writerPool) + return nil, err + } + reader := &Endpoint{pool: readerPool, coordinator: coordinator, manager: coordinator.Reader()} + writer := &Endpoint{pool: writerPool, coordinator: coordinator, manager: coordinator.Writer()} + managers, err := txmanager.NewManagers(reader, writer) + if err != nil { + closePools(readerPool, writerPool) + return nil, err + } + return &DB{ + reader: reader, + writer: writer, + managers: managers, + readerPool: readerPool, + writerPool: writerPool, + }, nil +} + +// Reader returns the reader endpoint. +func (db *DB) Reader() *Endpoint { + return db.reader +} + +// Writer returns the writer endpoint. +func (db *DB) Writer() *Endpoint { + return db.writer +} + +// TxManagers returns reader and writer transaction managers backed by this DB. +func (db *DB) TxManagers() txmanager.Managers { + return db.managers +} + +// Close releases all distinct pools once. +func (db *DB) Close() error { + db.closeOnce.Do(func() { + if db.readerPool != db.writerPool { + db.readerPool.Close() + } + db.writerPool.Close() + }) + return nil +} + +func closePools(reader *pgxpool.Pool, writer *pgxpool.Pool) { + if reader != writer { + reader.Close() + } + writer.Close() +} + +func parsePoolConfig(endpoint EndpointConfig, telemetry telemetryConfig, role string) (*pgxpool.Config, error) { + if err := validatePoolConfig(endpoint.Pool); err != nil { + return nil, err + } + config, err := pgxpool.ParseConfig(endpoint.DSN) + if err != nil { + return nil, fmt.Errorf("parse DSN: %w", err) + } + config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeExec + config.ConnConfig.Tracer = telemetry.tracer(role) + if endpoint.Pool.MaxConnections > 0 { + config.MaxConns = endpoint.Pool.MaxConnections + } + if endpoint.Pool.MinIdleConnections > 0 { + config.MinIdleConns = endpoint.Pool.MinIdleConnections + } + if endpoint.Pool.MaxConnectionLifetime > 0 { + config.MaxConnLifetime = endpoint.Pool.MaxConnectionLifetime + } + if endpoint.Pool.MaxConnectionLifetimeJitter > 0 { + config.MaxConnLifetimeJitter = endpoint.Pool.MaxConnectionLifetimeJitter + } + if endpoint.Pool.MaxConnectionIdleTime > 0 { + config.MaxConnIdleTime = endpoint.Pool.MaxConnectionIdleTime + } + if endpoint.Pool.HealthCheckPeriod > 0 { + config.HealthCheckPeriod = endpoint.Pool.HealthCheckPeriod + } + if config.MinIdleConns > config.MaxConns { + return nil, errors.New("minimum idle connections exceeds maximum connections") + } + return config, nil +} + +func validatePoolConfig(config PoolConfig) error { + if config.MaxConnections < 0 || config.MinIdleConnections < 0 || + config.MaxConnectionLifetime < 0 || config.MaxConnectionLifetimeJitter < 0 || + config.MaxConnectionIdleTime < 0 || config.HealthCheckPeriod < 0 { + return errors.New("pool values must not be negative") + } + return nil +} diff --git a/postgresdb/postgresdb_test.go b/postgresdb/postgresdb_test.go new file mode 100644 index 0000000..61d7786 --- /dev/null +++ b/postgresdb/postgresdb_test.go @@ -0,0 +1,17 @@ +package postgresdb_test + +import ( + "context" + "testing" + + "github.com/devctllabs/go-libs/postgresdb" + "github.com/stretchr/testify/require" +) + +func TestOpenRejectsBlankWriterDSN(t *testing.T) { + t.Parallel() + db, err := postgresdb.Open(context.Background(), postgresdb.Config{}) + + require.Nil(t, db) + require.Error(t, err) +} diff --git a/postgresdb/telemetry.go b/postgresdb/telemetry.go new file mode 100644 index 0000000..39a358a --- /dev/null +++ b/postgresdb/telemetry.go @@ -0,0 +1,74 @@ +package postgresdb + +import ( + "github.com/exaring/otelpgx" + "github.com/jackc/pgx/v5/pgxpool" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" + semconv "go.opentelemetry.io/otel/semconv/v1.40.0" + "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +// Telemetry configures PostgreSQL tracing and pool metrics without consulting +// OpenTelemetry global providers. Query text is excluded by default and query +// parameters are never recorded. +type Telemetry struct { + TracerProvider trace.TracerProvider + MeterProvider metric.MeterProvider + IncludeQueryText bool +} + +type telemetryConfig struct { + tracerProvider trace.TracerProvider + meterProvider metric.MeterProvider + includeQueryText bool +} + +func newTelemetryConfig(config Telemetry) telemetryConfig { + tracerProvider := config.TracerProvider + if tracerProvider == nil { + tracerProvider = tracenoop.NewTracerProvider() + } + meterProvider := config.MeterProvider + if meterProvider == nil { + meterProvider = metricnoop.NewMeterProvider() + } + return telemetryConfig{ + tracerProvider: tracerProvider, + meterProvider: meterProvider, + includeQueryText: config.IncludeQueryText, + } +} + +func (config telemetryConfig) tracer(role string) *otelpgx.Tracer { + attributes := databaseAttributes(role) + options := []otelpgx.Option{ + otelpgx.WithTracerProvider(config.tracerProvider), + otelpgx.WithMeterProvider(config.meterProvider), + otelpgx.WithTracerAttributes(attributes...), + otelpgx.WithMeterAttributes(attributes...), + otelpgx.WithTrimSQLInSpanName(), + } + if !config.includeQueryText { + options = append(options, otelpgx.WithDisableSQLStatementInAttributes()) + } + return otelpgx.NewTracer(options...) +} + +func (config telemetryConfig) recordPoolStats(pool *pgxpool.Pool, role string) error { + return otelpgx.RecordStats( + pool, + otelpgx.WithStatsMeterProvider(config.meterProvider), + otelpgx.WithStatsAttributes(databaseAttributes(role)...), + ) +} + +func databaseAttributes(role string) []attribute.KeyValue { + return []attribute.KeyValue{ + semconv.DBSystemNamePostgreSQL, + semconv.DBClientConnectionPoolName(role), + attribute.String("db.role", role), + } +} diff --git a/postgresdb/telemetry_test.go b/postgresdb/telemetry_test.go new file mode 100644 index 0000000..ccd6b3f --- /dev/null +++ b/postgresdb/telemetry_test.go @@ -0,0 +1,94 @@ +package postgresdb + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +//nolint:paralleltest // The test temporarily replaces the process-global tracer provider. +func TestOpenConfiguresExplicitQueryTracingWithoutConnecting(t *testing.T) { + globalRecorder := tracetest.NewSpanRecorder() + globalProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(globalRecorder)) + previousProvider := otel.GetTracerProvider() + otel.SetTracerProvider(globalProvider) + t.Cleanup(func() { + otel.SetTracerProvider(previousProvider) + require.NoError(t, globalProvider.Shutdown(context.Background())) + }) + + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + db, err := Open(context.Background(), Config{ + Writer: EndpointConfig{DSN: "postgres://user:password@127.0.0.1:1/app"}, + Telemetry: Telemetry{ + TracerProvider: provider, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + tracer := db.writerPool.Config().ConnConfig.Tracer + parentCtx, parentSpan := provider.Tracer("postgresdb-test").Start(context.Background(), "parent") + traceCtx := tracer.TraceQueryStart(parentCtx, nil, pgx.TraceQueryStartData{ + SQL: `SELECT $1::text AS hidden_statement`, + Args: []any{"sensitive-argument"}, + }) + tracer.TraceQueryEnd(traceCtx, nil, pgx.TraceQueryEndData{}) + parentSpan.End() + + serialized := serializeUnitSpans(recorder.Ended()) + require.NotEmpty(t, recorder.Ended()) + require.Empty(t, globalRecorder.Ended()) + require.Contains(t, serialized, "db.role=writer") + require.NotContains(t, serialized, "hidden_statement") + require.NotContains(t, serialized, "sensitive-argument") +} + +func TestIncludeQueryTextRecordsStatementButNeverParameters(t *testing.T) { + t.Parallel() + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + db, err := Open(context.Background(), Config{ + Writer: EndpointConfig{DSN: "postgres://user:password@127.0.0.1:1/app"}, + Telemetry: Telemetry{ + TracerProvider: provider, + IncludeQueryText: true, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + tracer := db.writerPool.Config().ConnConfig.Tracer + parentCtx, parentSpan := provider.Tracer("postgresdb-test").Start(context.Background(), "parent") + traceCtx := tracer.TraceQueryStart(parentCtx, nil, pgx.TraceQueryStartData{ + SQL: `SELECT $1::text AS visible_statement`, + Args: []any{"sensitive-argument"}, + }) + tracer.TraceQueryEnd(traceCtx, nil, pgx.TraceQueryEndData{}) + parentSpan.End() + + serialized := serializeUnitSpans(recorder.Ended()) + require.Contains(t, serialized, "SELECT $1::text AS visible_statement") + require.NotContains(t, serialized, "sensitive-argument") +} + +func serializeUnitSpans(spans []sdktrace.ReadOnlySpan) string { + var serialized strings.Builder + for _, span := range spans { + _, _ = fmt.Fprintln(&serialized, span.Name()) + for _, attribute := range span.Attributes() { + _, _ = fmt.Fprintf(&serialized, "%s=%v\n", attribute.Key, attribute.Value.AsInterface()) + } + } + return serialized.String() +} diff --git a/postgresdb/transaction.go b/postgresdb/transaction.go new file mode 100644 index 0000000..5c97be1 --- /dev/null +++ b/postgresdb/transaction.go @@ -0,0 +1,53 @@ +package postgresdb + +import ( + "context" + + "github.com/devctllabs/go-libs/txmanager" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type transactionBackend struct { + reader *pgxpool.Pool + writer *pgxpool.Pool +} + +var _ txmanager.Backend[pgx.Tx] = (*transactionBackend)(nil) + +func (b *transactionBackend) Begin(ctx context.Context, spec txmanager.BeginSpec) (pgx.Tx, error) { + pool := b.writer + accessMode := pgx.ReadWrite + if spec.Role == txmanager.RoleReader { + pool = b.reader + accessMode = pgx.ReadOnly + } + return pool.BeginTx(ctx, pgx.TxOptions{ + IsoLevel: pgxIsolation(spec.Isolation), + AccessMode: accessMode, + }) +} + +func (*transactionBackend) Commit(ctx context.Context, tx pgx.Tx) error { + return tx.Commit(ctx) +} + +func (*transactionBackend) Rollback(ctx context.Context, tx pgx.Tx) error { + return tx.Rollback(ctx) +} + +func pgxIsolation(isolation *txmanager.Isolation) pgx.TxIsoLevel { + if isolation == nil { + return "" + } + switch *isolation { + case txmanager.ReadCommitted: + return pgx.ReadCommitted + case txmanager.RepeatableRead: + return pgx.RepeatableRead + case txmanager.Serializable: + return pgx.Serializable + default: + return "" + } +} diff --git a/sqlitedb/config_test.go b/sqlitedb/config_test.go new file mode 100644 index 0000000..46646ed --- /dev/null +++ b/sqlitedb/config_test.go @@ -0,0 +1,35 @@ +package sqlitedb + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpenAppliesBusyTimeoutAndReaderPoolConfig(t *testing.T) { + t.Parallel() + db, err := Open(context.Background(), Config{ + DSN: filepath.Join(t.TempDir(), "configured.sqlite"), + BusyTimeout: 1250 * time.Millisecond, + ReaderPool: PoolConfig{ + MaxOpenConnections: 3, + MaxIdleConnections: 2, + ConnectionMaxLifetime: time.Minute, + ConnectionMaxIdleTime: 30 * time.Second, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + require.Equal(t, 1, db.writerPool.Stats().MaxOpenConnections) + require.Equal(t, 3, db.readerPool.Stats().MaxOpenConnections) + for _, endpoint := range []*Endpoint{db.Writer(), db.Reader()} { + var timeoutMillis int + err = endpoint.QueryRowContext(context.Background(), `PRAGMA busy_timeout`).Scan(&timeoutMillis) + require.NoError(t, err) + require.Equal(t, 1250, timeoutMillis) + } +} diff --git a/sqlitedb/doc.go b/sqlitedb/doc.go new file mode 100644 index 0000000..2c180ea --- /dev/null +++ b/sqlitedb/doc.go @@ -0,0 +1,8 @@ +// Package sqlitedb owns an instrumented SQLite database with separate logical +// reader and writer endpoints. +// +// File databases use WAL mode, a single writer connection, and a read-only +// reader pool. Named shared in-memory databases are supported for tests and +// ephemeral applications. Endpoints route calls through transactions stored in +// context and are directly compatible with database/sql scanners such as scany. +package sqlitedb diff --git a/sqlitedb/endpoint.go b/sqlitedb/endpoint.go new file mode 100644 index 0000000..710401b --- /dev/null +++ b/sqlitedb/endpoint.go @@ -0,0 +1,54 @@ +package sqlitedb + +import ( + "context" + "database/sql" + + "github.com/devctllabs/go-libs/txmanager" +) + +// Endpoint executes SQLite queries against a role-bound pool or the active transaction in ctx. +type Endpoint struct { + pool *sql.DB + coordinator *txmanager.Coordinator[*sql.Tx] + manager txmanager.Manager +} + +// ExecContext executes query using the active transaction when ctx carries one. +func (e *Endpoint) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + if tx, ok := e.coordinator.Current(ctx); ok { + return tx.ExecContext(ctx, query, args...) + } + return e.pool.ExecContext(ctx, query, args...) +} + +// QueryContext queries rows using the active transaction when ctx carries one. +func (e *Endpoint) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + if tx, ok := e.coordinator.Current(ctx); ok { + return tx.QueryContext(ctx, query, args...) + } + return e.pool.QueryContext(ctx, query, args...) +} + +// QueryRowContext queries one row using the active transaction when ctx carries one. +func (e *Endpoint) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + if tx, ok := e.coordinator.Current(ctx); ok { + return tx.QueryRowContext(ctx, query, args...) + } + return e.pool.QueryRowContext(ctx, query, args...) +} + +// WithinTx executes fn inside a transaction bound to this endpoint's role. +func (e *Endpoint) WithinTx( + ctx context.Context, + fn func(ctx context.Context) error, + options ...txmanager.Option, +) error { + return e.manager.WithinTx(ctx, fn, options...) +} + +// Check verifies that this endpoint can execute a bounded query. +func (e *Endpoint) Check(ctx context.Context) error { + var one int + return e.pool.QueryRowContext(ctx, `SELECT 1`).Scan(&one) +} diff --git a/sqlitedb/example_test.go b/sqlitedb/example_test.go new file mode 100644 index 0000000..b7549dc --- /dev/null +++ b/sqlitedb/example_test.go @@ -0,0 +1,46 @@ +package sqlitedb_test + +import ( + "context" + "log" + "os" + + "github.com/devctllabs/go-libs/sqlitedb" + "github.com/georgysavva/scany/v2/sqlscan" +) + +func ExampleOpen() { + logger := log.New(os.Stdout, "", 0) + ctx := context.Background() + db, err := sqlitedb.Open(ctx, sqlitedb.Config{ + DSN: "file:sqlitedb-example?mode=memory&cache=shared", + }) + if err != nil { + log.Fatal(err) + } + defer func() { + if err := db.Close(); err != nil { + log.Printf("close database: %v", err) + } + }() + + _, err = db.Writer().ExecContext(ctx, `CREATE TABLE messages (body text NOT NULL)`) + if err != nil { + log.Print(err) + return + } + _, err = db.Writer().ExecContext(ctx, `INSERT INTO messages (body) VALUES (?)`, "hello") + if err != nil { + log.Print(err) + return + } + var messages []struct { + Body string `db:"body"` + } + if err := sqlscan.Select(ctx, db.Reader(), &messages, `SELECT body FROM messages`); err != nil { + log.Print(err) + return + } + logger.Println(messages[0].Body) + // Output: hello +} diff --git a/sqlitedb/go.mod b/sqlitedb/go.mod new file mode 100644 index 0000000..65f0535 --- /dev/null +++ b/sqlitedb/go.mod @@ -0,0 +1,34 @@ +module github.com/devctllabs/go-libs/sqlitedb + +go 1.25.0 + +require ( + github.com/XSAM/otelsql v0.41.0 + github.com/devctllabs/go-libs/txmanager v0.1.0 + github.com/georgysavva/scany/v2 v2.1.4 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + modernc.org/sqlite v1.54.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + golang.org/x/sys v0.46.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/sqlitedb/go.sum b/sqlitedb/go.sum new file mode 100644 index 0000000..b745b2a --- /dev/null +++ b/sqlitedb/go.sum @@ -0,0 +1,125 @@ +github.com/XSAM/otelsql v0.41.0 h1:uZifjQhZhv5EDYJh+IVk1DiYxQZJBlNSen0MBFnfxB8= +github.com/XSAM/otelsql v0.41.0/go.mod h1:NMQT0PiKoFILp9QgjQz+D5mvW+9mT0suR7OejqrtMaM= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgx/v5 v5.0.0 h1:3UdmB3yUeTnJtZ+nDv3Mxzd4GHHvHkl9XN3oboIbOrY= +github.com/jackc/pgx/v5 v5.0.0/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= +github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc= +github.com/jackc/puddle/v2 v2.0.0/go.mod h1:itE7ZJY8xnoo0JqJEpSMprN0f+NQkMCuEV/N9j8h0oc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E= +github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc= +github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/sqlitedb/sqlitedb.go b/sqlitedb/sqlitedb.go new file mode 100644 index 0000000..9af1c49 --- /dev/null +++ b/sqlitedb/sqlitedb.go @@ -0,0 +1,296 @@ +package sqlitedb + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "github.com/XSAM/otelsql" + "github.com/devctllabs/go-libs/txmanager" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + // Register the modernc SQLite driver used by Open. + _ "modernc.org/sqlite" +) + +// Config defines one SQLite database instance. +type Config struct { + DSN string + BusyTimeout time.Duration + ReaderPool PoolConfig + Telemetry Telemetry +} + +// Telemetry supplies instance-scoped OpenTelemetry dependencies. +type Telemetry struct { + TracerProvider trace.TracerProvider + MeterProvider metric.MeterProvider + IncludeQueryText bool +} + +// PoolConfig controls the SQLite reader connection pool. +type PoolConfig struct { + MaxOpenConnections int + MaxIdleConnections int + ConnectionMaxLifetime time.Duration + ConnectionMaxIdleTime time.Duration +} + +// DB owns the SQLite reader and writer resources. +type DB struct { + reader *Endpoint + writer *Endpoint + managers txmanager.Managers + readerPool *sql.DB + writerPool *sql.DB + metrics []metric.Registration + closeOnce sync.Once + closeErr error +} + +// Open constructs a SQLite database instance. +func Open(ctx context.Context, cfg Config) (*DB, error) { + if ctx == nil { + return nil, errors.New("sqlitedb: context must not be nil") + } + if strings.TrimSpace(cfg.DSN) == "" { + return nil, errors.New("sqlitedb: DSN must not be blank") + } + busyTimeout, readerPoolConfig, err := validateConfig(cfg) + if err != nil { + return nil, err + } + writerDSN, readerDSN, memory, err := connectionDSNs(cfg.DSN, busyTimeout) + if err != nil { + return nil, err + } + resources := &sqliteResources{} + if err := resources.openWriter(ctx, cfg.Telemetry, writerDSN, memory); err != nil { + return nil, resources.cleanup(err) + } + if err := resources.openReader(ctx, cfg.Telemetry, readerDSN, readerPoolConfig); err != nil { + return nil, resources.cleanup(err) + } + db, err := resources.buildDB() + if err != nil { + return nil, resources.cleanup(err) + } + return db, nil +} + +type sqliteResources struct { + readerPool *sql.DB + writerPool *sql.DB + readerMetrics metric.Registration + writerMetrics metric.Registration +} + +func (r *sqliteResources) openWriter(ctx context.Context, telemetry Telemetry, dsn string, memory bool) error { + writerOptions, writerMetricOptions := telemetryOptions(telemetry, "writer") + writerPool, err := otelsql.Open("sqlite", dsn, writerOptions...) + if err != nil { + return fmt.Errorf("sqlitedb: open writer: %w", err) + } + r.writerPool = writerPool + writerPool.SetMaxOpenConns(1) + writerPool.SetMaxIdleConns(1) + if err := writerPool.PingContext(ctx); err != nil { + return fmt.Errorf("sqlitedb: ping writer: %w", err) + } + if err := enableWAL(ctx, writerPool, memory); err != nil { + return err + } + r.writerMetrics, err = otelsql.RegisterDBStatsMetrics(writerPool, writerMetricOptions...) + if err != nil { + return fmt.Errorf("sqlitedb: register writer metrics: %w", err) + } + return nil +} + +func enableWAL(ctx context.Context, writerPool *sql.DB, memory bool) error { + if memory { + return nil + } + var journalMode string + if err := writerPool.QueryRowContext(ctx, `PRAGMA journal_mode=WAL`).Scan(&journalMode); err != nil { + return fmt.Errorf("sqlitedb: enable WAL: %w", err) + } + if !strings.EqualFold(journalMode, "wal") { + return fmt.Errorf("sqlitedb: WAL is unavailable, journal mode is %q", journalMode) + } + return nil +} + +func (r *sqliteResources) openReader(ctx context.Context, telemetry Telemetry, dsn string, poolConfig PoolConfig) error { + readerOptions, readerMetricOptions := telemetryOptions(telemetry, "reader") + readerPool, err := otelsql.Open("sqlite", dsn, readerOptions...) + if err != nil { + return fmt.Errorf("sqlitedb: open reader: %w", err) + } + r.readerPool = readerPool + readerPool.SetMaxOpenConns(poolConfig.MaxOpenConnections) + readerPool.SetMaxIdleConns(poolConfig.MaxIdleConnections) + readerPool.SetConnMaxLifetime(poolConfig.ConnectionMaxLifetime) + readerPool.SetConnMaxIdleTime(poolConfig.ConnectionMaxIdleTime) + if err := readerPool.PingContext(ctx); err != nil { + return fmt.Errorf("sqlitedb: ping reader: %w", err) + } + r.readerMetrics, err = otelsql.RegisterDBStatsMetrics(readerPool, readerMetricOptions...) + if err != nil { + return fmt.Errorf("sqlitedb: register reader metrics: %w", err) + } + return nil +} + +func (r *sqliteResources) buildDB() (*DB, error) { + backend := &transactionBackend{reader: r.readerPool, writer: r.writerPool} + coordinator, err := txmanager.NewCoordinator[*sql.Tx](backend) + if err != nil { + return nil, err + } + reader := &Endpoint{pool: r.readerPool, coordinator: coordinator, manager: coordinator.Reader()} + writer := &Endpoint{pool: r.writerPool, coordinator: coordinator, manager: coordinator.Writer()} + managers, err := txmanager.NewManagers(reader, writer) + if err != nil { + return nil, err + } + return &DB{ + reader: reader, writer: writer, managers: managers, + readerPool: r.readerPool, writerPool: r.writerPool, + metrics: []metric.Registration{r.readerMetrics, r.writerMetrics}, + }, nil +} + +func (r *sqliteResources) cleanup(cause error) error { + errorsToJoin := []error{cause} + if r.readerMetrics != nil { + errorsToJoin = append(errorsToJoin, r.readerMetrics.Unregister()) + } + if r.readerPool != nil { + errorsToJoin = append(errorsToJoin, r.readerPool.Close()) + } + if r.writerMetrics != nil { + errorsToJoin = append(errorsToJoin, r.writerMetrics.Unregister()) + } + if r.writerPool != nil { + errorsToJoin = append(errorsToJoin, r.writerPool.Close()) + } + if len(errorsToJoin) == 1 { + return cause + } + return errors.Join(errorsToJoin...) +} + +// Reader returns the read-only endpoint. +func (db *DB) Reader() *Endpoint { + return db.reader +} + +// Writer returns the read-write endpoint. +func (db *DB) Writer() *Endpoint { + return db.writer +} + +// TxManagers returns reader and writer transaction managers backed by this DB. +func (db *DB) TxManagers() txmanager.Managers { + return db.managers +} + +// Close releases both endpoint pools once. +func (db *DB) Close() error { + db.closeOnce.Do(func() { + metricErrors := make([]error, 0, len(db.metrics)) + for _, registration := range db.metrics { + metricErrors = append(metricErrors, registration.Unregister()) + } + db.closeErr = errors.Join( + errors.Join(metricErrors...), + db.readerPool.Close(), + db.writerPool.Close(), + ) + }) + return db.closeErr +} + +func validateConfig(cfg Config) (time.Duration, PoolConfig, error) { + busyTimeout := cfg.BusyTimeout + if busyTimeout == 0 { + busyTimeout = 5 * time.Second + } + if busyTimeout < time.Millisecond { + return 0, PoolConfig{}, errors.New("sqlitedb: busy timeout must be at least one millisecond") + } + pool := cfg.ReaderPool + if pool.MaxOpenConnections < 0 || pool.MaxIdleConnections < 0 || + pool.ConnectionMaxLifetime < 0 || pool.ConnectionMaxIdleTime < 0 { + return 0, PoolConfig{}, errors.New("sqlitedb: reader pool values must not be negative") + } + if pool.MaxOpenConnections == 0 { + pool.MaxOpenConnections = 1 + } + if pool.MaxIdleConnections == 0 { + pool.MaxIdleConnections = 1 + } + if pool.MaxIdleConnections > pool.MaxOpenConnections { + return 0, PoolConfig{}, errors.New("sqlitedb: reader max idle connections exceeds max open connections") + } + return busyTimeout, pool, nil +} + +func connectionDSNs(rawDSN string, busyTimeout time.Duration) (string, string, bool, error) { + if rawDSN == ":memory:" { + return "", "", false, errors.New("sqlitedb: bare :memory: DSN is not supported") + } + parsed, err := sqliteURI(rawDSN) + if err != nil { + return "", "", false, err + } + writerValues := parsed.Query() + memory := writerValues.Get("mode") == "memory" + if memory { + if writerValues.Get("cache") != "shared" || parsed.Opaque == "" && parsed.Path == "" { + return "", "", false, errors.New("sqlitedb: memory DSN must be named and use cache=shared") + } + } else { + writerValues.Set("mode", "rwc") + } + writerValues.Set("_txlock", "immediate") + writerValues.Add("_pragma", "foreign_keys(1)") + writerValues.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", busyTimeout.Milliseconds())) + parsed.RawQuery = writerValues.Encode() + writerDSN := parsed.String() + + readerValues := cloneValues(writerValues) + if !memory { + readerValues.Set("mode", "ro") + } + readerValues.Del("_txlock") + readerValues.Add("_pragma", "query_only(1)") + parsed.RawQuery = readerValues.Encode() + return writerDSN, parsed.String(), memory, nil +} + +func sqliteURI(rawDSN string) (*url.URL, error) { + if strings.HasPrefix(rawDSN, "file:") { + parsed, err := url.Parse(rawDSN) + if err != nil { + return nil, fmt.Errorf("sqlitedb: parse DSN: %w", err) + } + return parsed, nil + } + return &url.URL{Scheme: "file", Path: rawDSN}, nil +} + +func cloneValues(values url.Values) url.Values { + cloned := make(url.Values, len(values)) + for key, entries := range values { + cloned[key] = append([]string(nil), entries...) + } + return cloned +} diff --git a/sqlitedb/sqlitedb_test.go b/sqlitedb/sqlitedb_test.go new file mode 100644 index 0000000..3534c91 --- /dev/null +++ b/sqlitedb/sqlitedb_test.go @@ -0,0 +1,131 @@ +package sqlitedb_test + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/devctllabs/go-libs/sqlitedb" + "github.com/devctllabs/go-libs/txmanager" + "github.com/stretchr/testify/require" +) + +func TestOpenRejectsBlankDSN(t *testing.T) { + t.Parallel() + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{}) + + require.Nil(t, db) + require.Error(t, err) +} + +func TestOpenFileConfiguresWriterAndStrictReader(t *testing.T) { + t.Parallel() + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{ + DSN: filepath.Join(t.TempDir(), "app.sqlite"), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + _, err = db.Writer().ExecContext(context.Background(), `CREATE TABLE messages (id INTEGER PRIMARY KEY)`) + require.NoError(t, err) + _, err = db.Writer().ExecContext(context.Background(), `INSERT INTO messages DEFAULT VALUES`) + require.NoError(t, err) + + var count int + err = db.Reader().QueryRowContext(context.Background(), `SELECT COUNT(*) FROM messages`).Scan(&count) + require.NoError(t, err) + require.Equal(t, 1, count) + + _, err = db.Reader().ExecContext(context.Background(), `INSERT INTO messages DEFAULT VALUES`) + require.Error(t, err) + require.NoError(t, db.Writer().Check(context.Background())) + require.NoError(t, db.Reader().Check(context.Background())) + + var journalMode string + err = db.Writer().QueryRowContext(context.Background(), `PRAGMA journal_mode`).Scan(&journalMode) + require.NoError(t, err) + require.Equal(t, "wal", journalMode) + var queryOnly int + err = db.Reader().QueryRowContext(context.Background(), `PRAGMA query_only`).Scan(&queryOnly) + require.NoError(t, err) + require.Equal(t, 1, queryOnly) +} + +func TestOpenNamedMemorySharesStateWithStrictReader(t *testing.T) { + t.Parallel() + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{ + DSN: "file:sqlitedb-memory-test?mode=memory&cache=shared", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + var journalMode string + err = db.Writer().QueryRowContext(context.Background(), `PRAGMA journal_mode`).Scan(&journalMode) + require.NoError(t, err) + require.Equal(t, "memory", journalMode) + + _, err = db.Writer().ExecContext(context.Background(), `CREATE TABLE values_table (value TEXT NOT NULL)`) + require.NoError(t, err) + _, err = db.Writer().ExecContext(context.Background(), `INSERT INTO values_table VALUES ('shared')`) + require.NoError(t, err) + + var value string + err = db.Reader().QueryRowContext(context.Background(), `SELECT value FROM values_table`).Scan(&value) + require.NoError(t, err) + require.Equal(t, "shared", value) + _, err = db.Reader().ExecContext(context.Background(), `DELETE FROM values_table`) + require.Error(t, err) +} + +func TestOpenRejectsBareMemoryDSN(t *testing.T) { + t.Parallel() + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{DSN: ":memory:"}) + + require.Nil(t, db) + require.Error(t, err) +} + +func TestWriterTransactionRoutesReaderAndRollsBack(t *testing.T) { + t.Parallel() + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{ + DSN: filepath.Join(t.TempDir(), "transactions.sqlite"), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + _, err = db.Writer().ExecContext(context.Background(), `CREATE TABLE entries (value TEXT NOT NULL)`) + require.NoError(t, err) + + callbackErr := errors.New("rollback requested") + err = db.Writer().WithinTx(context.Background(), func(ctx context.Context) error { + _, insertErr := db.Writer().ExecContext(ctx, `INSERT INTO entries VALUES ('pending')`) + require.NoError(t, insertErr) + var count int + queryErr := db.Reader().QueryRowContext(ctx, `SELECT COUNT(*) FROM entries`).Scan(&count) + require.NoError(t, queryErr) + require.Equal(t, 1, count) + return callbackErr + }) + require.ErrorIs(t, err, callbackErr) + + var count int + err = db.Reader().QueryRowContext(context.Background(), `SELECT COUNT(*) FROM entries`).Scan(&count) + require.NoError(t, err) + require.Zero(t, count) + require.Same(t, db.Reader(), db.TxManagers().Reader()) + require.Same(t, db.Writer(), db.TxManagers().Writer()) +} + +func TestWriterTransactionCannotEscalateReaderTransaction(t *testing.T) { + t.Parallel() + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{ + DSN: filepath.Join(t.TempDir(), "read-only.sqlite"), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + err = db.Reader().WithinTx(context.Background(), func(ctx context.Context) error { + return db.Writer().WithinTx(ctx, func(context.Context) error { return nil }) + }) + + require.ErrorIs(t, err, txmanager.ErrReadOnlyEscalation) +} diff --git a/sqlitedb/telemetry.go b/sqlitedb/telemetry.go new file mode 100644 index 0000000..cb6e479 --- /dev/null +++ b/sqlitedb/telemetry.go @@ -0,0 +1,39 @@ +package sqlitedb + +import ( + "context" + + "github.com/XSAM/otelsql" + "go.opentelemetry.io/otel/attribute" + metricnoop "go.opentelemetry.io/otel/metric/noop" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +func telemetryOptions(config Telemetry, role string) ([]otelsql.Option, []otelsql.Option) { + tracerProvider := config.TracerProvider + if tracerProvider == nil { + tracerProvider = tracenoop.NewTracerProvider() + } + meterProvider := config.MeterProvider + if meterProvider == nil { + meterProvider = metricnoop.NewMeterProvider() + } + attributes := []attribute.KeyValue{ + attribute.String("db.system.name", "sqlite"), + attribute.String("db.client.connection.pool.name", role), + attribute.String("db.role", role), + } + common := []otelsql.Option{ + otelsql.WithTracerProvider(tracerProvider), + otelsql.WithMeterProvider(meterProvider), + otelsql.WithAttributes(attributes...), + } + query := append([]otelsql.Option(nil), common...) + query = append(query, + otelsql.WithSpanOptions(otelsql.SpanOptions{DisableQuery: !config.IncludeQueryText}), + otelsql.WithSpanNameFormatter(func(_ context.Context, method otelsql.Method, _ string) string { + return string(method) + }), + ) + return query, common +} diff --git a/sqlitedb/telemetry_test.go b/sqlitedb/telemetry_test.go new file mode 100644 index 0000000..4496d08 --- /dev/null +++ b/sqlitedb/telemetry_test.go @@ -0,0 +1,89 @@ +package sqlitedb_test + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/devctllabs/go-libs/sqlitedb" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +//nolint:paralleltest // The test temporarily replaces the process-global tracer provider. +func TestOpenUsesExplicitTracerAndProtectsQueryData(t *testing.T) { + globalRecorder := tracetest.NewSpanRecorder() + globalProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(globalRecorder)) + previousProvider := otel.GetTracerProvider() + otel.SetTracerProvider(globalProvider) + t.Cleanup(func() { + otel.SetTracerProvider(previousProvider) + require.NoError(t, globalProvider.Shutdown(context.Background())) + }) + + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{ + DSN: filepath.Join(t.TempDir(), "telemetry.sqlite"), + Telemetry: sqlitedb.Telemetry{ + TracerProvider: provider, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + var value string + err = db.Writer().QueryRowContext(context.Background(), `SELECT ?`, "sensitive-argument").Scan(&value) + require.NoError(t, err) + require.Equal(t, "sensitive-argument", value) + + serialized := serializeSpans(recorder.Ended()) + require.NotEmpty(t, recorder.Ended()) + require.Empty(t, globalRecorder.Ended()) + require.NotContains(t, serialized, "sensitive-argument") + require.NotContains(t, serialized, "SELECT ?") +} + +func TestIncludeQueryTextRecordsStatementButNotArguments(t *testing.T) { + t.Parallel() + recorder := tracetest.NewSpanRecorder() + provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + db, err := sqlitedb.Open(context.Background(), sqlitedb.Config{ + DSN: filepath.Join(t.TempDir(), "query-text.sqlite"), + Telemetry: sqlitedb.Telemetry{ + TracerProvider: provider, + IncludeQueryText: true, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + var value string + err = db.Writer().QueryRowContext( + context.Background(), + `SELECT ? AS visible_statement`, + "sensitive-argument", + ).Scan(&value) + require.NoError(t, err) + + serialized := serializeSpans(recorder.Ended()) + require.Contains(t, serialized, "SELECT ? AS visible_statement") + require.NotContains(t, serialized, "sensitive-argument") +} + +func serializeSpans(spans []sdktrace.ReadOnlySpan) string { + var serialized strings.Builder + for _, span := range spans { + _, _ = fmt.Fprintln(&serialized, span.Name()) + for _, attribute := range span.Attributes() { + _, _ = fmt.Fprintf(&serialized, "%s=%v\n", attribute.Key, attribute.Value.AsInterface()) + } + } + return serialized.String() +} diff --git a/sqlitedb/transaction.go b/sqlitedb/transaction.go new file mode 100644 index 0000000..46ff1d9 --- /dev/null +++ b/sqlitedb/transaction.go @@ -0,0 +1,33 @@ +package sqlitedb + +import ( + "context" + "database/sql" + + "github.com/devctllabs/go-libs/txmanager" +) + +type transactionBackend struct { + reader *sql.DB + writer *sql.DB +} + +var _ txmanager.Backend[*sql.Tx] = (*transactionBackend)(nil) + +func (b *transactionBackend) Begin(ctx context.Context, spec txmanager.BeginSpec) (*sql.Tx, error) { + pool := b.writer + readOnly := false + if spec.Role == txmanager.RoleReader { + pool = b.reader + readOnly = true + } + return pool.BeginTx(ctx, &sql.TxOptions{ReadOnly: readOnly}) +} + +func (*transactionBackend) Commit(_ context.Context, tx *sql.Tx) error { + return tx.Commit() +} + +func (*transactionBackend) Rollback(_ context.Context, tx *sql.Tx) error { + return tx.Rollback() +} diff --git a/telemetry/doc.go b/telemetry/doc.go new file mode 100644 index 0000000..cbe0fd4 --- /dev/null +++ b/telemetry/doc.go @@ -0,0 +1,11 @@ +// Package telemetry initializes instance-owned OpenTelemetry trace and metric providers for Go +// services. Open reads standard OTLP exporter, trace SDK, metric reader, and resource environment +// variables while keeping provider and propagator installation explicit at the application +// composition root. +// +// The zero Config is disabled and performs no environment parsing, network setup, or background +// work. An enabled Runtime exports traces and metrics through OTLP by default; set +// OTEL_TRACES_EXPORTER or OTEL_METRICS_EXPORTER to none to disable a signal. Logs remain the +// application's responsibility. SetGlobalLogger only connects OpenTelemetry's own diagnostics to +// zap, and WithTraceContext adds trace correlation fields to application logs. +package telemetry diff --git a/telemetry/environment.go b/telemetry/environment.go new file mode 100644 index 0000000..3d6310c --- /dev/null +++ b/telemetry/environment.go @@ -0,0 +1,96 @@ +package telemetry + +import ( + "fmt" + "os" +) + +const ( + tracesExporterEnv = "OTEL_TRACES_EXPORTER" + metricsExporterEnv = "OTEL_METRICS_EXPORTER" + generalProtocolEnv = "OTEL_EXPORTER_OTLP_PROTOCOL" + tracesProtocolEnv = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL" + metricsProtocolEnv = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL" +) + +type exporterKind uint8 + +const ( + exporterNone exporterKind = iota + exporterOTLP +) + +type otlpProtocol uint8 + +const ( + protocolHTTPProtobuf otlpProtocol = iota + protocolGRPC +) + +type signalConfig struct { + exporter exporterKind + protocol otlpProtocol +} + +type signalsConfig struct { + traces signalConfig + metrics signalConfig +} + +func readSignalConfig() (signalsConfig, error) { + traces, err := readSignal(tracesExporterEnv, tracesProtocolEnv) + if err != nil { + return signalsConfig{}, err + } + metrics, err := readSignal(metricsExporterEnv, metricsProtocolEnv) + if err != nil { + return signalsConfig{}, err + } + return signalsConfig{traces: traces, metrics: metrics}, nil +} + +func readSignal(exporterEnv, protocolEnv string) (signalConfig, error) { + exporter, err := readExporter(exporterEnv) + if err != nil { + return signalConfig{}, err + } + if exporter == exporterNone { + return signalConfig{exporter: exporterNone}, nil + } + protocol, err := readProtocol(protocolEnv) + if err != nil { + return signalConfig{}, err + } + return signalConfig{exporter: exporterOTLP, protocol: protocol}, nil +} + +func readExporter(key string) (exporterKind, error) { + switch value := os.Getenv(key); value { + case "", "otlp": + return exporterOTLP, nil + case "none": + return exporterNone, nil + default: + return exporterNone, fmt.Errorf("unsupported %s value %q: expected otlp or none", key, value) + } +} + +func readProtocol(signalKey string) (otlpProtocol, error) { + value := os.Getenv(signalKey) + key := signalKey + if value == "" { + value = os.Getenv(generalProtocolEnv) + key = generalProtocolEnv + } + if value == "" { + return protocolHTTPProtobuf, nil + } + switch value { + case "http/protobuf": + return protocolHTTPProtobuf, nil + case "grpc": + return protocolGRPC, nil + default: + return protocolHTTPProtobuf, fmt.Errorf("unsupported %s value %q: expected http/protobuf or grpc", key, value) + } +} diff --git a/telemetry/example_test.go b/telemetry/example_test.go new file mode 100644 index 0000000..2e2899e --- /dev/null +++ b/telemetry/example_test.go @@ -0,0 +1,26 @@ +package telemetry_test + +import ( + "context" + "log" + + "github.com/devctllabs/go-libs/telemetry" +) + +func ExampleOpen() { + ctx := context.Background() + runtime, err := telemetry.Open(ctx, telemetry.Config{}) + if err != nil { + log.Fatal(err) + } + defer func() { + if err := runtime.Shutdown(context.Background()); err != nil { + log.Printf("shutdown telemetry: %v", err) + } + }() + + _ = runtime.TracerProvider() + _ = runtime.MeterProvider() + _ = runtime.Propagator() + // Output: +} diff --git a/telemetry/exporters.go b/telemetry/exporters.go new file mode 100644 index 0000000..edd80b8 --- /dev/null +++ b/telemetry/exporters.go @@ -0,0 +1,60 @@ +package telemetry + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +func openTraceExporter(ctx context.Context, cfg signalConfig) (sdktrace.SpanExporter, error) { + if cfg.exporter == exporterNone { + return nil, nil + } + var ( + exporter sdktrace.SpanExporter + err error + ) + switch cfg.protocol { + case protocolHTTPProtobuf: + exporter, err = otlptracehttp.New(ctx) + case protocolGRPC: + exporter, err = otlptracegrpc.New(ctx) + } + if err != nil { + return nil, fmt.Errorf("open OTLP trace exporter: %w", err) + } + return exporter, nil +} + +func openMetricExporter(ctx context.Context, cfg signalConfig) (sdkmetric.Exporter, error) { + if cfg.exporter == exporterNone { + return nil, nil + } + var ( + exporter sdkmetric.Exporter + err error + ) + switch cfg.protocol { + case protocolHTTPProtobuf: + exporter, err = otlpmetrichttp.New(ctx) + case protocolGRPC: + exporter, err = otlpmetricgrpc.New(ctx) + } + if err != nil { + return nil, fmt.Errorf("open OTLP metric exporter: %w", err) + } + return exporter, nil +} + +func shutdownTraceExporter(ctx context.Context, exporter sdktrace.SpanExporter) error { + if exporter == nil { + return nil + } + return exporter.Shutdown(ctx) +} diff --git a/telemetry/go.mod b/telemetry/go.mod new file mode 100644 index 0000000..abd6269 --- /dev/null +++ b/telemetry/go.mod @@ -0,0 +1,42 @@ +module github.com/devctllabs/go-libs/telemetry + +go 1.25.0 + +require ( + github.com/go-logr/zapr v1.3.0 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/sdk/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/zap v1.28.0 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/telemetry/go.sum b/telemetry/go.sum new file mode 100644 index 0000000..a86d253 --- /dev/null +++ b/telemetry/go.sum @@ -0,0 +1,88 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0= +go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/telemetry/logging.go b/telemetry/logging.go new file mode 100644 index 0000000..1443938 --- /dev/null +++ b/telemetry/logging.go @@ -0,0 +1,34 @@ +package telemetry + +import ( + "context" + "errors" + + "github.com/go-logr/zapr" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" +) + +// SetGlobalLogger routes OpenTelemetry internal diagnostics to a named zap logger. +// It changes process-global OpenTelemetry logger state and does not restore the previous logger. +func SetGlobalLogger(logger *zap.Logger) error { + if logger == nil { + return errors.New("telemetry: global logger must not be nil") + } + otel.SetLogger(zapr.NewLogger(logger.Named("opentelemetry"))) + return nil +} + +// WithTraceContext returns logger enriched with trace_id and span_id when ctx contains a valid +// span context. It returns logger unchanged when no valid span context exists. +func WithTraceContext(ctx context.Context, logger *zap.Logger) *zap.Logger { + spanContext := trace.SpanContextFromContext(ctx) + if logger == nil || !spanContext.IsValid() { + return logger + } + return logger.With( + zap.String("trace_id", spanContext.TraceID().String()), + zap.String("span_id", spanContext.SpanID().String()), + ) +} diff --git a/telemetry/logging_test.go b/telemetry/logging_test.go new file mode 100644 index 0000000..36cf373 --- /dev/null +++ b/telemetry/logging_test.go @@ -0,0 +1,46 @@ +package telemetry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestSetGlobalLoggerRejectsNil(t *testing.T) { + t.Parallel() + require.Error(t, SetGlobalLogger(nil)) +} + +//nolint:paralleltest // SetGlobalLogger intentionally mutates process-global OpenTelemetry state. +func TestSetGlobalLoggerAcceptsZapLogger(t *testing.T) { + require.NoError(t, SetGlobalLogger(zap.NewNop())) +} + +func TestWithTraceContextReturnsSameLoggerWithoutValidSpan(t *testing.T) { + t.Parallel() + logger := zap.NewNop() + require.Same(t, logger, WithTraceContext(context.Background(), logger)) +} + +func TestWithTraceContextAddsTraceAndSpanIDs(t *testing.T) { + t.Parallel() + core, observed := observer.New(zap.InfoLevel) + logger := zap.New(core) + spanContext := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1, 2, 3}, + SpanID: trace.SpanID{4, 5, 6}, + }) + ctx := trace.ContextWithSpanContext(context.Background(), spanContext) + + WithTraceContext(ctx, logger).Info("handled") + + entries := observed.AllUntimed() + require.Len(t, entries, 1) + entry := entries[0] + require.Equal(t, spanContext.TraceID().String(), entry.ContextMap()["trace_id"]) + require.Equal(t, spanContext.SpanID().String(), entry.ContextMap()["span_id"]) +} diff --git a/telemetry/resource.go b/telemetry/resource.go new file mode 100644 index 0000000..116fedd --- /dev/null +++ b/telemetry/resource.go @@ -0,0 +1,22 @@ +package telemetry + +import ( + "context" + + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.41.0" +) + +func buildResource(ctx context.Context, cfg Config) (*resource.Resource, error) { + return resource.New( + ctx, + resource.WithService(), + resource.WithTelemetrySDK(), + resource.WithFromEnv(), + resource.WithAttributes( + semconv.ServiceName(cfg.ServiceName), + semconv.ServiceVersion(cfg.ServiceVersion), + semconv.DeploymentEnvironmentNameKey.String(cfg.DeploymentEnvironment), + ), + ) +} diff --git a/telemetry/resource_test.go b/telemetry/resource_test.go new file mode 100644 index 0000000..7c46115 --- /dev/null +++ b/telemetry/resource_test.go @@ -0,0 +1,37 @@ +package telemetry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + semconv "go.opentelemetry.io/otel/semconv/v1.41.0" +) + +func TestBuildResourceAppliesPrecedence(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.name=env-name,service.version=env-version,deployment.environment.name=env-environment,service.instance.id=env-instance,custom.key=custom-value") + + res, err := buildResource(context.Background(), validConfig()) + require.NoError(t, err) + require.Equal(t, "orders", resourceString(t, res.Set(), semconv.ServiceNameKey)) + require.Equal(t, "1.2.3", resourceString(t, res.Set(), semconv.ServiceVersionKey)) + require.Equal(t, "test", resourceString(t, res.Set(), semconv.DeploymentEnvironmentNameKey)) + require.Equal(t, "env-instance", resourceString(t, res.Set(), semconv.ServiceInstanceIDKey)) + require.Equal(t, "custom-value", resourceString(t, res.Set(), attribute.Key("custom.key"))) +} + +func TestBuildResourceGeneratesServiceInstanceID(t *testing.T) { + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "") + + res, err := buildResource(context.Background(), validConfig()) + require.NoError(t, err) + require.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, resourceString(t, res.Set(), semconv.ServiceInstanceIDKey)) +} + +func resourceString(t *testing.T, set *attribute.Set, key attribute.Key) string { + t.Helper() + value, ok := set.Value(key) + require.True(t, ok) + return value.AsString() +} diff --git a/telemetry/runtime_metrics_test.go b/telemetry/runtime_metrics_test.go new file mode 100644 index 0000000..df0b702 --- /dev/null +++ b/telemetry/runtime_metrics_test.go @@ -0,0 +1,29 @@ +package telemetry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func TestStartRuntimeMetricsRegistersGoRuntimeInstruments(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { require.NoError(t, provider.Shutdown(context.Background())) }) + + require.NoError(t, startRuntimeMetrics(provider)) + var data metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &data)) + + var names []string + for _, scope := range data.ScopeMetrics { + for _, metric := range scope.Metrics { + names = append(names, metric.Name) + } + } + require.Contains(t, names, "go.goroutine.count") +} diff --git a/telemetry/runtime_test.go b/telemetry/runtime_test.go new file mode 100644 index 0000000..08658ef --- /dev/null +++ b/telemetry/runtime_test.go @@ -0,0 +1,93 @@ +package telemetry + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +func TestRuntimeForceFlushJoinsSignalErrorsInMetricTraceOrder(t *testing.T) { + t.Parallel() + metricErr := errors.New("metric flush") + traceErr := errors.New("trace flush") + var order []string + runtime := newRuntime( + tracenoop.NewTracerProvider(), + metricnoop.NewMeterProvider(), + propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}), + func(context.Context) error { + order = append(order, "metric") + return metricErr + }, + func(context.Context) error { + order = append(order, "trace") + return traceErr + }, + nil, + nil, + ) + + err := runtime.ForceFlush(context.Background()) + require.ErrorIs(t, err, metricErr) + require.ErrorIs(t, err, traceErr) + require.Equal(t, []string{"metric", "trace"}, order) +} + +func TestRuntimeShutdownIsConcurrentSafeAndIdempotent(t *testing.T) { + t.Parallel() + metricErr := errors.New("metric shutdown") + traceErr := errors.New("trace shutdown") + var metricCalls atomic.Int32 + var traceCalls atomic.Int32 + var orderMu sync.Mutex + var order []string + runtime := newRuntime( + tracenoop.NewTracerProvider(), + metricnoop.NewMeterProvider(), + propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}), + nil, + nil, + func(context.Context) error { + metricCalls.Add(1) + orderMu.Lock() + order = append(order, "metric") + orderMu.Unlock() + return metricErr + }, + func(context.Context) error { + traceCalls.Add(1) + orderMu.Lock() + order = append(order, "trace") + orderMu.Unlock() + return traceErr + }, + ) + + const callers = 32 + errs := make(chan error, callers) + var wg sync.WaitGroup + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + errs <- runtime.Shutdown(context.Background()) + }() + } + wg.Wait() + close(errs) + + for err := range errs { + require.ErrorIs(t, err, metricErr) + require.ErrorIs(t, err, traceErr) + } + require.Equal(t, int32(1), metricCalls.Load()) + require.Equal(t, int32(1), traceCalls.Load()) + require.Equal(t, []string{"metric", "trace"}, order) +} diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go new file mode 100644 index 0000000..03df5bc --- /dev/null +++ b/telemetry/telemetry.go @@ -0,0 +1,264 @@ +package telemetry + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + otelruntime "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +// Config identifies the service and controls telemetry initialization. +// A zero Config disables telemetry and does not read OpenTelemetry environment variables. +type Config struct { + Enabled bool + ServiceName string + ServiceVersion string + DeploymentEnvironment string +} + +// Runtime owns the providers and lifecycle of one telemetry instance. +// It does not install process-global providers or propagators. +type Runtime struct { + tracerProvider trace.TracerProvider + meterProvider metric.MeterProvider + propagator propagation.TextMapPropagator + + metricForceFlush func(context.Context) error + traceForceFlush func(context.Context) error + metricShutdown func(context.Context) error + traceShutdown func(context.Context) error + + shutdownOnce sync.Once + shutdownErr error +} + +// Open constructs explicitly injectable trace and metric providers from Config and standard +// OpenTelemetry environment variables. Only OTLP push export and the none exporter are supported. +func Open(ctx context.Context, cfg Config) (*Runtime, error) { + propagator := newPropagator() + if !cfg.Enabled { + return newRuntime( + tracenoop.NewTracerProvider(), + metricnoop.NewMeterProvider(), + propagator, + nil, nil, nil, nil, + ), nil + } + if err := validateConfig(cfg); err != nil { + return nil, err + } + + signals, err := readSignalConfig() + if err != nil { + return nil, err + } + res, err := buildResource(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("build telemetry resource: %w", err) + } + providers, err := openTelemetryProviders(ctx, signals, res) + if err != nil { + return nil, err + } + return providers.runtime(propagator), nil +} + +type telemetryProviders struct { + tracerProvider trace.TracerProvider + meterProvider metric.MeterProvider + traceSDK *sdktrace.TracerProvider + metricSDK *sdkmetric.MeterProvider +} + +func openTelemetryProviders(ctx context.Context, signals signalsConfig, res *resource.Resource) (*telemetryProviders, error) { + traceExporter, err := openTraceExporter(ctx, signals.traces) + if err != nil { + return nil, err + } + metricExporter, err := openMetricExporter(ctx, signals.metrics) + if err != nil { + return nil, errors.Join(err, shutdownTraceExporter(ctx, traceExporter)) + } + tracerProvider, traceSDK := buildTracerProvider(res, traceExporter) + meterProvider, metricSDK, err := buildMeterProvider(res, metricExporter) + if err != nil { + return nil, errors.Join( + fmt.Errorf("start Go runtime metrics: %w", err), + metricSDK.Shutdown(ctx), + shutdownTracerProvider(ctx, traceSDK), + ) + } + return &telemetryProviders{ + tracerProvider: tracerProvider, + meterProvider: meterProvider, + traceSDK: traceSDK, + metricSDK: metricSDK, + }, nil +} + +func buildTracerProvider(res *resource.Resource, exporter sdktrace.SpanExporter) (trace.TracerProvider, *sdktrace.TracerProvider) { + if exporter == nil { + return tracenoop.NewTracerProvider(), nil + } + provider := sdktrace.NewTracerProvider( + sdktrace.WithResource(res), + sdktrace.WithBatcher(exporter), + ) + return provider, provider +} + +func buildMeterProvider(res *resource.Resource, exporter sdkmetric.Exporter) (metric.MeterProvider, *sdkmetric.MeterProvider, error) { + if exporter == nil { + return metricnoop.NewMeterProvider(), nil, nil + } + reader := sdkmetric.NewPeriodicReader(exporter) + provider := sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(reader), + ) + if err := startRuntimeMetrics(provider); err != nil { + return nil, provider, err + } + return provider, provider, nil +} + +func (p *telemetryProviders) runtime(propagator propagation.TextMapPropagator) *Runtime { + return newRuntime( + p.tracerProvider, + p.meterProvider, + propagator, + forceFlushMeterProvider(p.metricSDK), + forceFlushTracerProvider(p.traceSDK), + shutdownMeterProvider(p.metricSDK), + shutdownTracerProviderFunc(p.traceSDK), + ) +} + +// TracerProvider returns the instance-owned provider for explicit injection. +func (r *Runtime) TracerProvider() trace.TracerProvider { + return r.tracerProvider +} + +// MeterProvider returns the instance-owned provider for explicit injection. +func (r *Runtime) MeterProvider() metric.MeterProvider { + return r.meterProvider +} + +// Propagator returns the W3C Trace Context and Baggage propagator for explicit injection. +func (r *Runtime) Propagator() propagation.TextMapPropagator { + return r.propagator +} + +// ForceFlush immediately flushes metrics, then traces, and joins signal errors. +func (r *Runtime) ForceFlush(ctx context.Context) error { + return errors.Join(callLifecycle(ctx, r.metricForceFlush), callLifecycle(ctx, r.traceForceFlush)) +} + +// Shutdown stops metrics, then traces. It is safe for concurrent use and returns the first +// shutdown result to every caller. +func (r *Runtime) Shutdown(ctx context.Context) error { + r.shutdownOnce.Do(func() { + r.shutdownErr = errors.Join( + callLifecycle(ctx, r.metricShutdown), + callLifecycle(ctx, r.traceShutdown), + ) + }) + return r.shutdownErr +} + +func validateConfig(cfg Config) error { + if strings.TrimSpace(cfg.ServiceName) == "" { + return errors.New("telemetry Config.ServiceName must not be blank") + } + if strings.TrimSpace(cfg.ServiceVersion) == "" { + return errors.New("telemetry Config.ServiceVersion must not be blank") + } + if strings.TrimSpace(cfg.DeploymentEnvironment) == "" { + return errors.New("telemetry Config.DeploymentEnvironment must not be blank") + } + return nil +} + +func newPropagator() propagation.TextMapPropagator { + return propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) +} + +func newRuntime( + tracerProvider trace.TracerProvider, + meterProvider metric.MeterProvider, + propagator propagation.TextMapPropagator, + metricForceFlush func(context.Context) error, + traceForceFlush func(context.Context) error, + metricShutdown func(context.Context) error, + traceShutdown func(context.Context) error, +) *Runtime { + return &Runtime{ + tracerProvider: tracerProvider, + meterProvider: meterProvider, + propagator: propagator, + metricForceFlush: metricForceFlush, + traceForceFlush: traceForceFlush, + metricShutdown: metricShutdown, + traceShutdown: traceShutdown, + } +} + +func startRuntimeMetrics(provider metric.MeterProvider) error { + return otelruntime.Start(otelruntime.WithMeterProvider(provider)) +} + +func callLifecycle(ctx context.Context, call func(context.Context) error) error { + if call == nil { + return nil + } + return call(ctx) +} + +func forceFlushMeterProvider(provider *sdkmetric.MeterProvider) func(context.Context) error { + if provider == nil { + return nil + } + return provider.ForceFlush +} + +func forceFlushTracerProvider(provider *sdktrace.TracerProvider) func(context.Context) error { + if provider == nil { + return nil + } + return provider.ForceFlush +} + +func shutdownMeterProvider(provider *sdkmetric.MeterProvider) func(context.Context) error { + if provider == nil { + return nil + } + return provider.Shutdown +} + +func shutdownTracerProviderFunc(provider *sdktrace.TracerProvider) func(context.Context) error { + if provider == nil { + return nil + } + return provider.Shutdown +} + +func shutdownTracerProvider(ctx context.Context, provider *sdktrace.TracerProvider) error { + if provider == nil { + return nil + } + return provider.Shutdown(ctx) +} diff --git a/telemetry/telemetry_test.go b/telemetry/telemetry_test.go new file mode 100644 index 0000000..b04ba0a --- /dev/null +++ b/telemetry/telemetry_test.go @@ -0,0 +1,163 @@ +package telemetry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/baggage" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +func TestOpenDisabledUsesNoopProvidersAndW3CPropagation(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "unsupported") + t.Setenv("OTEL_METRICS_EXPORTER", "unsupported") + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "unsupported") + t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "broken") + + runtime, err := Open(context.Background(), Config{}) + require.NoError(t, err) + require.IsType(t, tracenoop.NewTracerProvider(), runtime.TracerProvider()) + require.IsType(t, metricnoop.NewMeterProvider(), runtime.MeterProvider()) + require.ElementsMatch(t, []string{"traceparent", "tracestate", "baggage"}, runtime.Propagator().Fields()) + + member, err := baggage.NewMember("tenant", "acme") + require.NoError(t, err) + bag, err := baggage.New(member) + require.NoError(t, err) + spanContext := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: trace.TraceID{1}, + SpanID: trace.SpanID{2}, + TraceFlags: trace.FlagsSampled, + }) + ctx := baggage.ContextWithBaggage(trace.ContextWithSpanContext(context.Background(), spanContext), bag) + carrier := propagation.MapCarrier{} + runtime.Propagator().Inject(ctx, carrier) + require.NotEmpty(t, carrier.Get("traceparent")) + require.Equal(t, "tenant=acme", carrier.Get("baggage")) + + require.NoError(t, runtime.ForceFlush(context.Background())) + require.NoError(t, runtime.Shutdown(context.Background())) + require.NoError(t, runtime.Shutdown(context.Background())) +} + +//nolint:paralleltest // The test temporarily replaces process-global OpenTelemetry providers. +func TestOpenDoesNotInstallGlobalProvidersOrPropagator(t *testing.T) { + setSignalsNone(t) + globalTracerProvider := tracenoop.NewTracerProvider() + globalMeterProvider := metricnoop.NewMeterProvider() + globalPropagator := propagation.NewCompositeTextMapPropagator(propagation.Baggage{}) + previousTracerProvider := otel.GetTracerProvider() + previousMeterProvider := otel.GetMeterProvider() + previousPropagator := otel.GetTextMapPropagator() + otel.SetTracerProvider(globalTracerProvider) + otel.SetMeterProvider(globalMeterProvider) + otel.SetTextMapPropagator(globalPropagator) + t.Cleanup(func() { + otel.SetTracerProvider(previousTracerProvider) + otel.SetMeterProvider(previousMeterProvider) + otel.SetTextMapPropagator(previousPropagator) + }) + + runtime, err := Open(context.Background(), validConfig()) + require.NoError(t, err) + require.Equal(t, globalTracerProvider, otel.GetTracerProvider()) + require.Equal(t, globalMeterProvider, otel.GetMeterProvider()) + require.Equal(t, globalPropagator, otel.GetTextMapPropagator()) + require.NoError(t, runtime.Shutdown(context.Background())) +} + +//nolint:paralleltest // setSignalsNone changes process environment for this test and its subtests. +func TestOpenEnabledValidatesServiceIdentity(t *testing.T) { + setSignalsNone(t) + + tests := []struct { + name string + config Config + field string + }{ + {name: "service name", config: Config{Enabled: true, ServiceVersion: "1.2.3", DeploymentEnvironment: "test"}, field: "ServiceName"}, + {name: "service version", config: Config{Enabled: true, ServiceName: "orders", DeploymentEnvironment: "test"}, field: "ServiceVersion"}, + {name: "deployment environment", config: Config{Enabled: true, ServiceName: "orders", ServiceVersion: "1.2.3"}, field: "DeploymentEnvironment"}, + {name: "blank value", config: Config{Enabled: true, ServiceName: "orders", ServiceVersion: " ", DeploymentEnvironment: "test"}, field: "ServiceVersion"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Open(context.Background(), tt.config) + require.Nil(t, got) + require.ErrorContains(t, err, tt.field) + }) + } +} + +func TestOpenEnabledAllowsSignalsToBeDisabledIndependently(t *testing.T) { + setSignalsNone(t) + t.Setenv("OTEL_PROPAGATORS", "b3") + + runtime, err := Open(context.Background(), validConfig()) + require.NoError(t, err) + require.IsType(t, tracenoop.NewTracerProvider(), runtime.TracerProvider()) + require.IsType(t, metricnoop.NewMeterProvider(), runtime.MeterProvider()) + require.ElementsMatch(t, []string{"traceparent", "tracestate", "baggage"}, runtime.Propagator().Fields()) + require.NoError(t, runtime.Shutdown(context.Background())) +} + +func TestOpenRejectsUnsupportedExporter(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "console") + t.Setenv("OTEL_METRICS_EXPORTER", "none") + + runtime, err := Open(context.Background(), validConfig()) + require.Nil(t, runtime) + require.ErrorContains(t, err, "OTEL_TRACES_EXPORTER") +} + +func TestOpenRejectsMultipleExporters(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "otlp,none") + t.Setenv("OTEL_METRICS_EXPORTER", "none") + + runtime, err := Open(context.Background(), validConfig()) + require.Nil(t, runtime) + require.ErrorContains(t, err, "OTEL_TRACES_EXPORTER") +} + +func TestOpenRejectsUnsupportedProtocol(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "otlp") + t.Setenv("OTEL_METRICS_EXPORTER", "none") + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "http/json") + + runtime, err := Open(context.Background(), validConfig()) + require.Nil(t, runtime) + require.ErrorContains(t, err, "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") +} + +func TestSignalProtocolOverridesGeneralProtocol(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "otlp") + t.Setenv("OTEL_METRICS_EXPORTER", "none") + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "unsupported") + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "http/protobuf") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318") + + runtime, err := Open(context.Background(), validConfig()) + require.NoError(t, err) + require.NoError(t, runtime.Shutdown(context.Background())) +} + +func validConfig() Config { + return Config{ + Enabled: true, + ServiceName: "orders", + ServiceVersion: "1.2.3", + DeploymentEnvironment: "test", + } +} + +func setSignalsNone(t *testing.T) { + t.Helper() + t.Setenv("OTEL_TRACES_EXPORTER", "none") + t.Setenv("OTEL_METRICS_EXPORTER", "none") +} diff --git a/txmanager/coordinator.go b/txmanager/coordinator.go new file mode 100644 index 0000000..7ff1467 --- /dev/null +++ b/txmanager/coordinator.go @@ -0,0 +1,82 @@ +package txmanager + +import ( + "context" + "errors" +) + +// Backend adapts one database driver's native transaction type to Coordinator. +// +//go:generate go tool mockgen -destination mocks/backend.gen.go -package mocks . Backend +type Backend[T any] interface { + // Begin starts a native transaction using spec. + Begin(ctx context.Context, spec BeginSpec) (T, error) + // Commit commits tx. + Commit(ctx context.Context, tx T) error + // Rollback rolls back tx. + Rollback(ctx context.Context, tx T) error +} + +// BeginSpec describes the transaction requested from a Backend. +type BeginSpec struct { + Role Role + Isolation *Isolation +} + +// Coordinator coordinates transactions for one database instance. +type Coordinator[T any] struct { + backend Backend[T] + key *contextKey + reader Manager + writer Manager +} + +type contextKey struct{} + +type transactionState[T any] struct { + tx T + role Role + isolation *Isolation +} + +// NewCoordinator constructs a transaction coordinator for backend. +func NewCoordinator[T any](backend Backend[T]) (*Coordinator[T], error) { + if backend == nil { + return nil, errors.New("txmanager: backend must not be nil") + } + coordinator := &Coordinator[T]{backend: backend, key: &contextKey{}} + coordinator.reader = &boundManager[T]{coordinator: coordinator, role: RoleReader} + coordinator.writer = &boundManager[T]{coordinator: coordinator, role: RoleWriter} + return coordinator, nil +} + +// Reader returns the read-only transaction manager. +func (c *Coordinator[T]) Reader() Manager { + return c.reader +} + +// Writer returns the read-write transaction manager. +func (c *Coordinator[T]) Writer() Manager { + return c.writer +} + +// Current returns the native transaction associated with ctx for this coordinator. +func (c *Coordinator[T]) Current(ctx context.Context) (T, bool) { + var zero T + if ctx == nil { + return zero, false + } + state, ok := ctx.Value(c.key).(transactionState[T]) + if !ok { + return zero, false + } + return state.tx, true +} + +func (c *Coordinator[T]) state(ctx context.Context) (transactionState[T], bool) { + if ctx == nil { + return transactionState[T]{}, false + } + state, ok := ctx.Value(c.key).(transactionState[T]) + return state, ok +} diff --git a/txmanager/coordinator_test.go b/txmanager/coordinator_test.go new file mode 100644 index 0000000..ecd009a --- /dev/null +++ b/txmanager/coordinator_test.go @@ -0,0 +1,186 @@ +package txmanager_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/go-libs/txmanager" + "github.com/devctllabs/go-libs/txmanager/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestNewCoordinatorRejectsNilBackend(t *testing.T) { + t.Parallel() + coordinator, err := txmanager.NewCoordinator[struct{}](nil) + + require.Nil(t, coordinator) + require.Error(t, err) +} + +func TestWriterWithinTxCommitsSuccessfulCallback(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + backend.EXPECT().Begin(gomock.Any(), txmanager.BeginSpec{Role: txmanager.RoleWriter}).Return("tx", nil) + backend.EXPECT().Commit(gomock.Any(), "tx").Return(nil) + + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + called := false + err = coordinator.Writer().WithinTx(context.Background(), func(ctx context.Context) error { + called = true + require.NotNil(t, ctx) + return nil + }) + + require.NoError(t, err) + require.True(t, called) +} + +func TestWithinTxJoinsCallbackAndRollbackErrors(t *testing.T) { + t.Parallel() + callbackErr := errors.New("callback failed") + rollbackErr := errors.New("rollback failed") + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + backend.EXPECT().Begin(gomock.Any(), txmanager.BeginSpec{Role: txmanager.RoleWriter}).Return("tx", nil) + backend.EXPECT().Rollback(gomock.Any(), "tx").Return(rollbackErr) + + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + err = coordinator.Writer().WithinTx(context.Background(), func(context.Context) error { + return callbackErr + }) + + require.ErrorIs(t, err, callbackErr) + require.ErrorIs(t, err, rollbackErr) +} + +func TestWithinTxRollsBackBeforeRepanicking(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + gomock.InOrder( + backend.EXPECT().Begin(gomock.Any(), txmanager.BeginSpec{Role: txmanager.RoleWriter}).Return("tx", nil), + backend.EXPECT().Rollback(gomock.Any(), "tx").Return(nil), + ) + + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + require.PanicsWithValue(t, "boom", func() { + _ = coordinator.Writer().WithinTx(context.Background(), func(context.Context) error { + panic("boom") + }) + }) +} + +func TestCurrentReturnsTransactionOnlyInsideCallback(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + backend.EXPECT().Begin(gomock.Any(), txmanager.BeginSpec{Role: txmanager.RoleWriter}).Return("tx", nil) + backend.EXPECT().Commit(gomock.Any(), "tx").Return(nil) + + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + _, ok := coordinator.Current(context.Background()) + require.False(t, ok) + err = coordinator.Writer().WithinTx(context.Background(), func(ctx context.Context) error { + current, currentOK := coordinator.Current(ctx) + require.True(t, currentOK) + require.Equal(t, "tx", current) + return nil + }) + require.NoError(t, err) +} + +func TestReaderInsideWriterReusesActiveTransaction(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + backend.EXPECT().Begin(gomock.Any(), txmanager.BeginSpec{Role: txmanager.RoleWriter}).Return("tx", nil) + backend.EXPECT().Commit(gomock.Any(), "tx").Return(nil) + + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + err = coordinator.Writer().WithinTx(context.Background(), func(ctx context.Context) error { + return coordinator.Reader().WithinTx(ctx, func(nestedCtx context.Context) error { + current, ok := coordinator.Current(nestedCtx) + require.True(t, ok) + require.Equal(t, "tx", current) + return nil + }) + }) + require.NoError(t, err) +} + +func TestWriterInsideReaderReturnsReadOnlyEscalation(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + backend.EXPECT().Begin(gomock.Any(), txmanager.BeginSpec{Role: txmanager.RoleReader}).Return("tx", nil) + backend.EXPECT().Rollback(gomock.Any(), "tx").Return(nil) + + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + nestedCalled := false + err = coordinator.Reader().WithinTx(context.Background(), func(ctx context.Context) error { + return coordinator.Writer().WithinTx(ctx, func(context.Context) error { + nestedCalled = true + return nil + }) + }) + + require.ErrorIs(t, err, txmanager.ErrReadOnlyEscalation) + require.False(t, nestedCalled) +} + +func TestNestedDifferentIsolationReturnsMismatch(t *testing.T) { + t.Parallel() + outerIsolation := txmanager.Serializable + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + backend.EXPECT().Begin(gomock.Any(), txmanager.BeginSpec{ + Role: txmanager.RoleWriter, + Isolation: &outerIsolation, + }).Return("tx", nil) + backend.EXPECT().Rollback(gomock.Any(), "tx").Return(nil) + + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + err = coordinator.Writer().WithinTx( + context.Background(), + func(ctx context.Context) error { + return coordinator.Reader().WithinTx( + ctx, + func(context.Context) error { return nil }, + txmanager.WithIsolation(txmanager.ReadCommitted), + ) + }, + txmanager.WithIsolation(txmanager.Serializable), + ) + + require.ErrorIs(t, err, txmanager.ErrIsolationMismatch) +} + +func TestManagersReturnsConfiguredRoles(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + backend := mocks.NewMockBackend[string](ctrl) + coordinator, err := txmanager.NewCoordinator[string](backend) + require.NoError(t, err) + + managers, err := txmanager.NewManagers(coordinator.Reader(), coordinator.Writer()) + require.NoError(t, err) + require.Same(t, coordinator.Reader(), managers.Reader()) + require.Same(t, coordinator.Writer(), managers.Writer()) +} diff --git a/txmanager/doc.go b/txmanager/doc.go new file mode 100644 index 0000000..0732269 --- /dev/null +++ b/txmanager/doc.go @@ -0,0 +1,9 @@ +// Package txmanager defines the transaction boundary shared by service packages +// and database adapters. +// +// Services depend on Manager or Managers instead of repeating backend-specific +// transaction interfaces. Adapters create a Coordinator over their native +// transaction type and expose its reader and writer views. A transaction is +// carried in context and remains private to the adapter that knows how to route +// queries to it. +package txmanager diff --git a/txmanager/errors.go b/txmanager/errors.go new file mode 100644 index 0000000..997e4fc --- /dev/null +++ b/txmanager/errors.go @@ -0,0 +1,12 @@ +package txmanager + +import "errors" + +var ( + // ErrInvalidIsolation reports an isolation value outside the supported portable subset. + ErrInvalidIsolation = errors.New("txmanager: invalid isolation") + // ErrIsolationMismatch reports a nested transaction requesting a different isolation. + ErrIsolationMismatch = errors.New("txmanager: nested isolation does not match active transaction") + // ErrReadOnlyEscalation reports a writer transaction requested inside a reader transaction. + ErrReadOnlyEscalation = errors.New("txmanager: cannot start writer transaction inside reader transaction") +) diff --git a/txmanager/example_test.go b/txmanager/example_test.go new file mode 100644 index 0000000..d86f34b --- /dev/null +++ b/txmanager/example_test.go @@ -0,0 +1,34 @@ +package txmanager_test + +import ( + "context" + + "github.com/devctllabs/go-libs/txmanager" +) + +type accountStore interface { + Debit(ctx context.Context, accountID string, amount int64) error + Credit(ctx context.Context, accountID string, amount int64) error +} + +func transfer( + ctx context.Context, + transactions txmanager.Managers, + accounts accountStore, + from string, + to string, + amount int64, +) error { + return transactions.Writer().WithinTx(ctx, func(txCtx context.Context) error { + if err := accounts.Debit(txCtx, from, amount); err != nil { + return err + } + return accounts.Credit(txCtx, to, amount) + }) +} + +func ExampleManagers() { + // Application services can accept txmanager.Managers regardless of whether + // the composition root selected SQLite or PostgreSQL. + _ = transfer +} diff --git a/txmanager/go.mod b/txmanager/go.mod new file mode 100644 index 0000000..2b3ba4b --- /dev/null +++ b/txmanager/go.mod @@ -0,0 +1,19 @@ +module github.com/devctllabs/go-libs/txmanager + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.11.1 + go.uber.org/mock v0.6.0 +) + +tool go.uber.org/mock/mockgen + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/txmanager/go.sum b/txmanager/go.sum new file mode 100644 index 0000000..746df6c --- /dev/null +++ b/txmanager/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/txmanager/manager.go b/txmanager/manager.go new file mode 100644 index 0000000..0f62246 --- /dev/null +++ b/txmanager/manager.go @@ -0,0 +1,115 @@ +package txmanager + +import ( + "context" + "errors" + "fmt" +) + +// Role identifies the database endpoint that starts a transaction. +type Role uint8 + +const ( + // RoleReader starts a read-only transaction. + RoleReader Role = iota + 1 + // RoleWriter starts a read-write transaction. + RoleWriter +) + +// Manager executes callbacks inside transactions owned by one endpoint role. +type Manager interface { + // WithinTx executes fn in a transaction and commits only when fn returns nil. + WithinTx(ctx context.Context, fn func(ctx context.Context) error, options ...Option) error +} + +// Option configures one WithinTx invocation. +type Option interface { + apply(options *txOptions) error +} + +type txOptions struct { + isolation *Isolation +} + +type boundManager[T any] struct { + coordinator *Coordinator[T] + role Role +} + +func (m *boundManager[T]) WithinTx( + ctx context.Context, + fn func(ctx context.Context) error, + options ...Option, +) error { + if ctx == nil { + return errors.New("txmanager: context must not be nil") + } + if fn == nil { + return errors.New("txmanager: callback must not be nil") + } + configured, err := applyOptions(options) + if err != nil { + return err + } + if active, ok := m.coordinator.state(ctx); ok { + if active.role == RoleReader && m.role == RoleWriter { + return ErrReadOnlyEscalation + } + if configured.isolation != nil && !sameIsolation(active.isolation, configured.isolation) { + return ErrIsolationMismatch + } + return fn(ctx) + } + + spec := BeginSpec{Role: m.role, Isolation: configured.isolation} + tx, err := m.coordinator.backend.Begin(ctx, spec) + if err != nil { + return fmt.Errorf("txmanager: begin transaction: %w", err) + } + txCtx := context.WithValue(ctx, m.coordinator.key, transactionState[T]{ + tx: tx, + role: m.role, + isolation: configured.isolation, + }) + defer func() { + if recovered := recover(); recovered != nil { + _ = m.coordinator.backend.Rollback(ctx, tx) + panic(recovered) + } + }() + if callbackErr := fn(txCtx); callbackErr != nil { + rollbackErr := m.coordinator.backend.Rollback(ctx, tx) + return errors.Join(callbackErr, wrapRollback(rollbackErr)) + } + if err := m.coordinator.backend.Commit(ctx, tx); err != nil { + return fmt.Errorf("txmanager: commit transaction: %w", err) + } + return nil +} + +func applyOptions(options []Option) (txOptions, error) { + configured := txOptions{} + for _, option := range options { + if option == nil { + continue + } + if err := option.apply(&configured); err != nil { + return txOptions{}, err + } + } + return configured, nil +} + +func sameIsolation(left *Isolation, right *Isolation) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + +func wrapRollback(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("txmanager: rollback transaction: %w", err) +} diff --git a/txmanager/managers.go b/txmanager/managers.go new file mode 100644 index 0000000..a600d96 --- /dev/null +++ b/txmanager/managers.go @@ -0,0 +1,38 @@ +package txmanager + +import "errors" + +// Managers provides transaction managers bound to reader and writer roles. +type Managers interface { + // Reader returns the read-only transaction manager. + Reader() Manager + // Writer returns the read-write transaction manager. + Writer() Manager +} + +// ManagerSet is an immutable pair of reader and writer transaction managers. +type ManagerSet struct { + reader Manager + writer Manager +} + +// NewManagers constructs an immutable reader/writer manager pair. +func NewManagers(reader Manager, writer Manager) (*ManagerSet, error) { + if reader == nil { + return nil, errors.New("txmanager: reader manager must not be nil") + } + if writer == nil { + return nil, errors.New("txmanager: writer manager must not be nil") + } + return &ManagerSet{reader: reader, writer: writer}, nil +} + +// Reader returns the configured reader manager. +func (m *ManagerSet) Reader() Manager { + return m.reader +} + +// Writer returns the configured writer manager. +func (m *ManagerSet) Writer() Manager { + return m.writer +} diff --git a/txmanager/mocks/backend.gen.go b/txmanager/mocks/backend.gen.go new file mode 100644 index 0000000..1930b55 --- /dev/null +++ b/txmanager/mocks/backend.gen.go @@ -0,0 +1,85 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/go-libs/txmanager (interfaces: Backend) +// +// Generated by this command: +// +// mockgen -destination mocks/backend.gen.go -package mocks . Backend +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + txmanager "github.com/devctllabs/go-libs/txmanager" + gomock "go.uber.org/mock/gomock" +) + +// MockBackend is a mock of Backend interface. +type MockBackend[T any] struct { + ctrl *gomock.Controller + recorder *MockBackendMockRecorder[T] + isgomock struct{} +} + +// MockBackendMockRecorder is the mock recorder for MockBackend. +type MockBackendMockRecorder[T any] struct { + mock *MockBackend[T] +} + +// NewMockBackend creates a new mock instance. +func NewMockBackend[T any](ctrl *gomock.Controller) *MockBackend[T] { + mock := &MockBackend[T]{ctrl: ctrl} + mock.recorder = &MockBackendMockRecorder[T]{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBackend[T]) EXPECT() *MockBackendMockRecorder[T] { + return m.recorder +} + +// Begin mocks base method. +func (m *MockBackend[T]) Begin(ctx context.Context, spec txmanager.BeginSpec) (T, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Begin", ctx, spec) + ret0, _ := ret[0].(T) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Begin indicates an expected call of Begin. +func (mr *MockBackendMockRecorder[T]) Begin(ctx, spec any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Begin", reflect.TypeOf((*MockBackend[T])(nil).Begin), ctx, spec) +} + +// Commit mocks base method. +func (m *MockBackend[T]) Commit(ctx context.Context, tx T) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Commit", ctx, tx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Commit indicates an expected call of Commit. +func (mr *MockBackendMockRecorder[T]) Commit(ctx, tx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Commit", reflect.TypeOf((*MockBackend[T])(nil).Commit), ctx, tx) +} + +// Rollback mocks base method. +func (m *MockBackend[T]) Rollback(ctx context.Context, tx T) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Rollback", ctx, tx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Rollback indicates an expected call of Rollback. +func (mr *MockBackendMockRecorder[T]) Rollback(ctx, tx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rollback", reflect.TypeOf((*MockBackend[T])(nil).Rollback), ctx, tx) +} diff --git a/txmanager/options.go b/txmanager/options.go new file mode 100644 index 0000000..fcdc227 --- /dev/null +++ b/txmanager/options.go @@ -0,0 +1,35 @@ +package txmanager + +import "fmt" + +// Isolation identifies a portable transaction isolation level. +type Isolation uint8 + +const ( + // ReadCommitted allows each statement to observe data committed before that statement began. + ReadCommitted Isolation = iota + 1 + // RepeatableRead keeps rows read by a transaction stable for its lifetime. + RepeatableRead + // Serializable requires transactions to behave as though executed sequentially. + Serializable +) + +type isolationOption struct { + isolation Isolation +} + +// WithIsolation requests isolation for a transaction. +func WithIsolation(isolation Isolation) Option { + return isolationOption{isolation: isolation} +} + +func (o isolationOption) apply(options *txOptions) error { + switch o.isolation { + case ReadCommitted, RepeatableRead, Serializable: + isolation := o.isolation + options.isolation = &isolation + return nil + default: + return fmt.Errorf("%w: %d", ErrInvalidIsolation, o.isolation) + } +}