mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Merge remote-tracking branch 'origin/main' into codex/prefix-compaction-prewarm
This commit is contained in:
@@ -30,7 +30,7 @@ Accept any of the following:
|
||||
5. If the failure is likely caused by the current branch, patch code locally, commit, and push.
|
||||
6. If `process_review_comment` is present, inspect surfaced review items and decide whether to address them.
|
||||
7. If a review item is actionable and correct, patch code locally, commit, push, and then mark the associated review thread/comment as resolved once the fix is on GitHub.
|
||||
8. If a review item from another author is non-actionable, already addressed, or not valid, post one reply on the comment/thread explaining that decision (for example answering the question or explaining why no change is needed). If the watcher later surfaces your own reply, treat that self-authored item as already handled and do not reply again.
|
||||
8. If a review item from another author is non-actionable, already addressed, or not valid, post one reply on the comment/thread explaining that decision (for example answering the question or explaining why no change is needed). Prefix the GitHub reply body with `[codex]` so it is clear the response is automated. If the watcher later surfaces your own reply, treat that self-authored item as already handled and do not reply again.
|
||||
9. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`.
|
||||
10. If both actionable review feedback and `retry_failed_checks` are present, prioritize review feedback first; a new commit will retrigger CI, so avoid rerunning flaky checks on the old SHA unless you intentionally defer the review change.
|
||||
11. On every loop, look for newly surfaced review feedback before acting on CI failures or mergeability state, then verify mergeability / merge-conflict status (for example via `gh pr view`) alongside CI.
|
||||
@@ -99,7 +99,7 @@ When you agree with a comment and it is actionable:
|
||||
5. Resume watching on the new SHA immediately (do not stop after reporting the push).
|
||||
6. If monitoring was running in `--watch` mode, restart `--watch` immediately after the push in the same turn; do not wait for the user to ask again.
|
||||
|
||||
If you disagree or the comment is non-actionable/already addressed, reply once directly on the GitHub comment/thread so the reviewer gets an explicit answer, then continue the watcher loop. If the watcher later surfaces your own reply because the authenticated operator is treated as a trusted review author, treat that self-authored item as already handled and do not reply again.
|
||||
If you disagree or the comment is non-actionable/already addressed, reply once directly on the GitHub comment/thread so the reviewer gets an explicit answer, then continue the watcher loop. Prefix any GitHub reply to a code review comment/thread with `[codex]` so it is clear the response is automated and not from the human user. If the watcher later surfaces your own reply because the authenticated operator is treated as a trusted review author, treat that self-authored item as already handled and do not reply again.
|
||||
If a code review comment/thread is already marked as resolved in GitHub, treat it as non-actionable and safely ignore it unless new unresolved follow-up feedback appears.
|
||||
|
||||
## Git Safety Rules
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Core crate ownership.
|
||||
/codex-rs/core/ @openai/codex-core-agent-team
|
||||
|
||||
# Keep ownership changes reviewed by the same team.
|
||||
/.github/CODEOWNERS @openai/codex-core-agent-team
|
||||
@@ -4,6 +4,9 @@ inputs:
|
||||
target:
|
||||
description: Target triple used for setup and cache namespacing.
|
||||
required: true
|
||||
cache-scope:
|
||||
description: Logical namespace used to keep concurrent Bazel jobs from reserving the same repository cache key.
|
||||
required: true
|
||||
install-test-prereqs:
|
||||
description: Install Node.js and DotSlash for Bazel-backed test jobs.
|
||||
required: false
|
||||
@@ -12,6 +15,12 @@ outputs:
|
||||
repository-cache-path:
|
||||
description: Filesystem path used for the Bazel repository cache.
|
||||
value: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
repository-cache-key:
|
||||
description: Primary actions/cache key for the Bazel repository cache.
|
||||
value: ${{ steps.cache_bazel_repository_key.outputs.repository-cache-key }}
|
||||
repository-cache-hit:
|
||||
description: Whether the Bazel repository cache restore found an exact key match.
|
||||
value: ${{ steps.cache_bazel_repository_restore.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -23,6 +32,17 @@ runs:
|
||||
target: ${{ inputs.target }}
|
||||
install-test-prereqs: ${{ inputs.install-test-prereqs }}
|
||||
|
||||
- name: Compute bazel repository cache key
|
||||
id: cache_bazel_repository_key
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_SCOPE: ${{ inputs.cache-scope }}
|
||||
TARGET: ${{ inputs.target }}
|
||||
CACHE_HASH: ${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
run: |
|
||||
echo "repository-cache-key=bazel-cache-${CACHE_SCOPE}-${TARGET}-${CACHE_HASH}" >> "${GITHUB_OUTPUT}"
|
||||
echo "repository-cache-restore-key=bazel-cache-${CACHE_SCOPE}-${TARGET}-" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Restore the Bazel repository cache explicitly so external dependencies
|
||||
# do not need to be re-downloaded on every CI run. Keep restore failures
|
||||
# non-fatal so transient cache-service errors degrade to a cold build
|
||||
@@ -33,9 +53,9 @@ runs:
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ inputs.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
key: ${{ steps.cache_bazel_repository_key.outputs.repository-cache-key }}
|
||||
restore-keys: |
|
||||
bazel-cache-${{ inputs.target }}
|
||||
${{ steps.cache_bazel_repository_key.outputs.repository-cache-restore-key }}
|
||||
|
||||
- name: Set up Bazel execution logs
|
||||
shell: bash
|
||||
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
uses: ./.github/actions/prepare-bazel-ci
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-scope: bazel-${{ github.job }}
|
||||
install-test-prereqs: "true"
|
||||
- name: Check MODULE.bazel.lock is up to date
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
@@ -113,15 +114,15 @@ jobs:
|
||||
path: ${{ runner.temp }}/bazel-execution-logs
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Save the Bazel repository cache after every non-cancelled run. Keep the
|
||||
# Save the job-scoped Bazel repository cache after cache misses. Keep the
|
||||
# upload non-fatal so cache service issues never fail the job itself.
|
||||
- name: Save bazel repository cache
|
||||
if: always() && !cancelled()
|
||||
if: always() && !cancelled() && steps.prepare_bazel.outputs.repository-cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: ${{ steps.prepare_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
key: ${{ steps.prepare_bazel.outputs.repository-cache-key }}
|
||||
|
||||
clippy:
|
||||
timeout-minutes: 30
|
||||
@@ -150,6 +151,7 @@ jobs:
|
||||
uses: ./.github/actions/prepare-bazel-ci
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-scope: bazel-${{ github.job }}
|
||||
|
||||
- name: bazel build --config=clippy lint targets
|
||||
env:
|
||||
@@ -196,15 +198,15 @@ jobs:
|
||||
path: ${{ runner.temp }}/bazel-execution-logs
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Save the Bazel repository cache after every non-cancelled run. Keep the
|
||||
# Save the job-scoped Bazel repository cache after cache misses. Keep the
|
||||
# upload non-fatal so cache service issues never fail the job itself.
|
||||
- name: Save bazel repository cache
|
||||
if: always() && !cancelled()
|
||||
if: always() && !cancelled() && steps.prepare_bazel.outputs.repository-cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: ${{ steps.prepare_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
key: ${{ steps.prepare_bazel.outputs.repository-cache-key }}
|
||||
|
||||
verify-release-build:
|
||||
timeout-minutes: 30
|
||||
@@ -229,6 +231,7 @@ jobs:
|
||||
uses: ./.github/actions/prepare-bazel-ci
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-scope: bazel-${{ github.job }}
|
||||
|
||||
- name: bazel build verify-release-build targets
|
||||
env:
|
||||
@@ -278,12 +281,12 @@ jobs:
|
||||
path: ${{ runner.temp }}/bazel-execution-logs
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Save the Bazel repository cache after every non-cancelled run. Keep the
|
||||
# Save the job-scoped Bazel repository cache after cache misses. Keep the
|
||||
# upload non-fatal so cache service issues never fail the job itself.
|
||||
- name: Save bazel repository cache
|
||||
if: always() && !cancelled()
|
||||
if: always() && !cancelled() && steps.prepare_bazel.outputs.repository-cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||
with:
|
||||
path: ${{ steps.prepare_bazel.outputs.repository-cache-path }}
|
||||
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
|
||||
key: ${{ steps.prepare_bazel.outputs.repository-cache-key }}
|
||||
|
||||
Generated
+2
@@ -1409,6 +1409,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_matches",
|
||||
"async-channel",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -1842,6 +1843,7 @@ dependencies = [
|
||||
name = "codex-code-mode"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-trait",
|
||||
"codex-protocol",
|
||||
"deno_core_icudata",
|
||||
|
||||
@@ -2645,6 +2645,13 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SortDirection": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
"properties": {
|
||||
"byteRange": {
|
||||
@@ -2857,6 +2864,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional sort direction; defaults to descending (newest first)."
|
||||
},
|
||||
"sortKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3361,6 +3379,44 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadTurnsListParams": {
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional turn page size.",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional turn pagination direction; defaults to descending."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUnarchiveParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
@@ -4052,6 +4108,30 @@
|
||||
"title": "Thread/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/turns/list"
|
||||
],
|
||||
"title": "Thread/turns/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadTurnsListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/turns/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Append raw Responses API items to the thread history without starting a user turn.",
|
||||
"properties": {
|
||||
|
||||
@@ -2063,6 +2063,16 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitReachedType": {
|
||||
"enum": [
|
||||
"rate_limit_reached",
|
||||
"workspace_owner_credits_depleted",
|
||||
"workspace_member_credits_depleted",
|
||||
"workspace_owner_usage_limit_reached",
|
||||
"workspace_member_usage_limit_reached"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
@@ -2107,6 +2117,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitReachedType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RateLimitReachedType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"secondary": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -578,6 +578,30 @@
|
||||
"title": "Thread/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/turns/list"
|
||||
],
|
||||
"title": "Thread/turns/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ThreadTurnsListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/turns/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Append raw Responses API items to the thread history without starting a user turn.",
|
||||
"properties": {
|
||||
@@ -10492,6 +10516,44 @@
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sha": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"git"
|
||||
],
|
||||
"title": "GitPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -10685,6 +10747,16 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitReachedType": {
|
||||
"enum": [
|
||||
"rate_limit_reached",
|
||||
"workspace_owner_credits_depleted",
|
||||
"workspace_member_credits_depleted",
|
||||
"workspace_owner_usage_limit_reached",
|
||||
"workspace_member_usage_limit_reached"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
@@ -10729,6 +10801,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitReachedType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/RateLimitReachedType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"secondary": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -12576,6 +12658,13 @@
|
||||
"title": "SkillsListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SortDirection": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SubAgentSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -13847,6 +13936,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional sort direction; defaults to descending (newest first)."
|
||||
},
|
||||
"sortKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -13875,6 +13975,13 @@
|
||||
"ThreadListResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"backwardsCursor": {
|
||||
"description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/Thread"
|
||||
@@ -14936,6 +15043,76 @@
|
||||
"title": "ThreadTokenUsageUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadTurnsListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional turn page size.",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional turn pagination direction; defaults to descending."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadTurnsListParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadTurnsListResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"backwardsCursor": {
|
||||
"description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one turn. Use it with the opposite `sortDirection` to include the anchor turn again and catch updates to that turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/Turn"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"nextCursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last turn. if None, there are no more turns to return.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"title": "ThreadTurnsListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUnarchiveParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
|
||||
@@ -1160,6 +1160,30 @@
|
||||
"title": "Thread/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/turns/list"
|
||||
],
|
||||
"title": "Thread/turns/listRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadTurnsListParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/turns/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Append raw Responses API items to the thread history without starting a user turn.",
|
||||
"properties": {
|
||||
@@ -7244,6 +7268,44 @@
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sha": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"git"
|
||||
],
|
||||
"title": "GitPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -7437,6 +7499,16 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitReachedType": {
|
||||
"enum": [
|
||||
"rate_limit_reached",
|
||||
"workspace_owner_credits_depleted",
|
||||
"workspace_member_credits_depleted",
|
||||
"workspace_owner_usage_limit_reached",
|
||||
"workspace_member_usage_limit_reached"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
@@ -7481,6 +7553,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitReachedType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RateLimitReachedType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"secondary": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -10420,6 +10502,13 @@
|
||||
"title": "SkillsListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SortDirection": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SubAgentSource": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -11691,6 +11780,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional sort direction; defaults to descending (newest first)."
|
||||
},
|
||||
"sortKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11719,6 +11819,13 @@
|
||||
"ThreadListResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"backwardsCursor": {
|
||||
"description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/Thread"
|
||||
@@ -12780,6 +12887,76 @@
|
||||
"title": "ThreadTokenUsageUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadTurnsListParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional turn page size.",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional turn pagination direction; defaults to descending."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadTurnsListParams",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadTurnsListResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"backwardsCursor": {
|
||||
"description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one turn. Use it with the opposite `sortDirection` to include the anchor turn again and catch updates to that turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/Turn"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"nextCursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last turn. if None, there are no more turns to return.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"title": "ThreadTurnsListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUnarchiveParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
|
||||
@@ -39,6 +39,16 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitReachedType": {
|
||||
"enum": [
|
||||
"rate_limit_reached",
|
||||
"workspace_owner_credits_depleted",
|
||||
"workspace_member_credits_depleted",
|
||||
"workspace_owner_usage_limit_reached",
|
||||
"workspace_member_usage_limit_reached"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
@@ -83,6 +93,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitReachedType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RateLimitReachedType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"secondary": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -39,6 +39,16 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitReachedType": {
|
||||
"enum": [
|
||||
"rate_limit_reached",
|
||||
"workspace_owner_credits_depleted",
|
||||
"workspace_member_credits_depleted",
|
||||
"workspace_owner_usage_limit_reached",
|
||||
"workspace_member_usage_limit_reached"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
@@ -83,6 +93,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"rateLimitReachedType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RateLimitReachedType"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"secondary": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -204,6 +204,44 @@
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sha": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"git"
|
||||
],
|
||||
"title": "GitPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -224,6 +224,44 @@
|
||||
],
|
||||
"title": "LocalPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sha": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"git"
|
||||
],
|
||||
"title": "GitPluginSourceType",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"url"
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"SortDirection": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadSortKey": {
|
||||
"enum": [
|
||||
"created_at",
|
||||
@@ -72,6 +79,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional sort direction; defaults to descending (newest first)."
|
||||
},
|
||||
"sortKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -1970,6 +1970,13 @@
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"backwardsCursor": {
|
||||
"description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one thread. Use it with the opposite `sortDirection`; for timestamp sorts it anchors at the start of the page timestamp so same-second updates are not skipped.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/Thread"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"SortDirection": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"description": "Optional turn page size.",
|
||||
"format": "uint32",
|
||||
"minimum": 0.0,
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortDirection": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SortDirection"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional turn pagination direction; defaults to descending."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadTurnsListParams",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginSource = { "type": "local", path: AbsolutePathBuf, };
|
||||
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type RateLimitReachedType = "rate_limit_reached" | "workspace_owner_credits_depleted" | "workspace_member_credits_depleted" | "workspace_owner_usage_limit_reached" | "workspace_member_usage_limit_reached";
|
||||
@@ -3,6 +3,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PlanType } from "../PlanType";
|
||||
import type { CreditsSnapshot } from "./CreditsSnapshot";
|
||||
import type { RateLimitReachedType } from "./RateLimitReachedType";
|
||||
import type { RateLimitWindow } from "./RateLimitWindow";
|
||||
|
||||
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, };
|
||||
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type SortDirection = "asc" | "desc";
|
||||
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SortDirection } from "./SortDirection";
|
||||
import type { ThreadSortKey } from "./ThreadSortKey";
|
||||
import type { ThreadSourceKind } from "./ThreadSourceKind";
|
||||
|
||||
@@ -17,6 +18,10 @@ limit?: number | null,
|
||||
* Optional sort key; defaults to created_at.
|
||||
*/
|
||||
sortKey?: ThreadSortKey | null,
|
||||
/**
|
||||
* Optional sort direction; defaults to descending (newest first).
|
||||
*/
|
||||
sortDirection?: SortDirection | null,
|
||||
/**
|
||||
* Optional provider filter; when set, only sessions recorded under these
|
||||
* providers are returned. When present but empty, includes all providers.
|
||||
|
||||
@@ -8,4 +8,11 @@ export type ThreadListResponse = { data: Array<Thread>,
|
||||
* 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 | null, };
|
||||
nextCursor: string | null,
|
||||
/**
|
||||
* Opaque cursor to pass as `cursor` when reversing `sortDirection`.
|
||||
* This is only populated when the page contains at least one thread.
|
||||
* Use it with the opposite `sortDirection`; for timestamp sorts it anchors
|
||||
* at the start of the page timestamp so same-second updates are not skipped.
|
||||
*/
|
||||
backwardsCursor: string | null, };
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SortDirection } from "./SortDirection";
|
||||
|
||||
export type ThreadTurnsListParams = { threadId: string,
|
||||
/**
|
||||
* Opaque cursor to pass to the next call to continue after the last turn.
|
||||
*/
|
||||
cursor?: string | null,
|
||||
/**
|
||||
* Optional turn page size.
|
||||
*/
|
||||
limit?: number | null,
|
||||
/**
|
||||
* Optional turn pagination direction; defaults to descending.
|
||||
*/
|
||||
sortDirection?: SortDirection | null, };
|
||||
@@ -0,0 +1,18 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Turn } from "./Turn";
|
||||
|
||||
export type ThreadTurnsListResponse = { data: Array<Turn>,
|
||||
/**
|
||||
* Opaque cursor to pass to the next call to continue after the last turn.
|
||||
* if None, there are no more turns to return.
|
||||
*/
|
||||
nextCursor: string | null,
|
||||
/**
|
||||
* Opaque cursor to pass as `cursor` when reversing `sortDirection`.
|
||||
* This is only populated when the page contains at least one turn.
|
||||
* Use it with the opposite `sortDirection` to include the anchor turn again
|
||||
* and catch updates to that turn.
|
||||
*/
|
||||
backwardsCursor: string | null, };
|
||||
@@ -242,6 +242,7 @@ export type { PluginUninstallParams } from "./PluginUninstallParams";
|
||||
export type { PluginUninstallResponse } from "./PluginUninstallResponse";
|
||||
export type { PluginsMigration } from "./PluginsMigration";
|
||||
export type { ProfileV2 } from "./ProfileV2";
|
||||
export type { RateLimitReachedType } from "./RateLimitReachedType";
|
||||
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
export type { RateLimitWindow } from "./RateLimitWindow";
|
||||
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
|
||||
@@ -275,6 +276,7 @@ export type { SkillsListEntry } from "./SkillsListEntry";
|
||||
export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd";
|
||||
export type { SkillsListParams } from "./SkillsListParams";
|
||||
export type { SkillsListResponse } from "./SkillsListResponse";
|
||||
export type { SortDirection } from "./SortDirection";
|
||||
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
|
||||
export type { TextElement } from "./TextElement";
|
||||
export type { TextPosition } from "./TextPosition";
|
||||
@@ -330,6 +332,8 @@ export type { ThreadStatus } from "./ThreadStatus";
|
||||
export type { ThreadStatusChangedNotification } from "./ThreadStatusChangedNotification";
|
||||
export type { ThreadTokenUsage } from "./ThreadTokenUsage";
|
||||
export type { ThreadTokenUsageUpdatedNotification } from "./ThreadTokenUsageUpdatedNotification";
|
||||
export type { ThreadTurnsListParams } from "./ThreadTurnsListParams";
|
||||
export type { ThreadTurnsListResponse } from "./ThreadTurnsListResponse";
|
||||
export type { ThreadUnarchiveParams } from "./ThreadUnarchiveParams";
|
||||
export type { ThreadUnarchiveResponse } from "./ThreadUnarchiveResponse";
|
||||
export type { ThreadUnarchivedNotification } from "./ThreadUnarchivedNotification";
|
||||
|
||||
@@ -327,6 +327,10 @@ client_request_definitions! {
|
||||
params: v2::ThreadReadParams,
|
||||
response: v2::ThreadReadResponse,
|
||||
},
|
||||
ThreadTurnsList => "thread/turns/list" {
|
||||
params: v2::ThreadTurnsListParams,
|
||||
response: v2::ThreadTurnsListResponse,
|
||||
},
|
||||
/// Append raw Responses API items to the thread history without starting a user turn.
|
||||
ThreadInjectItems => "thread/inject_items" {
|
||||
params: v2::ThreadInjectItemsParams,
|
||||
|
||||
@@ -994,8 +994,15 @@ impl ThreadHistoryBuilder {
|
||||
}
|
||||
|
||||
fn new_turn(&mut self, id: Option<String>) -> PendingTurn {
|
||||
let id = id.unwrap_or_else(|| {
|
||||
if self.next_rollout_index == 0 {
|
||||
Uuid::now_v7().to_string()
|
||||
} else {
|
||||
format!("rollout-{}", self.current_rollout_index)
|
||||
}
|
||||
});
|
||||
PendingTurn {
|
||||
id: id.unwrap_or_else(|| Uuid::now_v7().to_string()),
|
||||
id,
|
||||
items: Vec::new(),
|
||||
error: None,
|
||||
status: TurnStatus::Completed,
|
||||
@@ -1643,8 +1650,8 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
let turns = build_turns_from_rollout_items(&items);
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert!(Uuid::parse_str(&turns[0].id).is_ok());
|
||||
assert!(Uuid::parse_str(&turns[1].id).is_ok());
|
||||
assert_eq!(turns[0].id, "rollout-0");
|
||||
assert_eq!(turns[1].id, "rollout-5");
|
||||
assert_ne!(turns[0].id, turns[1].id);
|
||||
assert_eq!(turns[0].status, TurnStatus::Completed);
|
||||
assert_eq!(turns[1].status, TurnStatus::Completed);
|
||||
|
||||
@@ -71,6 +71,7 @@ use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
|
||||
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind;
|
||||
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
|
||||
use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess;
|
||||
@@ -3263,6 +3264,9 @@ pub struct ThreadListParams {
|
||||
/// Optional sort key; defaults to created_at.
|
||||
#[ts(optional = nullable)]
|
||||
pub sort_key: Option<ThreadSortKey>,
|
||||
/// Optional sort direction; defaults to descending (newest first).
|
||||
#[ts(optional = nullable)]
|
||||
pub sort_direction: Option<SortDirection>,
|
||||
/// Optional provider filter; when set, only sessions recorded under these
|
||||
/// providers are returned. When present but empty, includes all providers.
|
||||
#[ts(optional = nullable)]
|
||||
@@ -3310,6 +3314,14 @@ pub enum ThreadSortKey {
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -3318,6 +3330,11 @@ pub struct ThreadListResponse {
|
||||
/// Opaque cursor to pass to the next call to continue after the last item.
|
||||
/// if None, there are no more items to return.
|
||||
pub next_cursor: Option<String>,
|
||||
/// Opaque cursor to pass as `cursor` when reversing `sortDirection`.
|
||||
/// This is only populated when the page contains at least one thread.
|
||||
/// Use it with the opposite `sortDirection`; for timestamp sorts it anchors
|
||||
/// at the start of the page timestamp so same-second updates are not skipped.
|
||||
pub backwards_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
|
||||
@@ -3383,6 +3400,37 @@ pub struct ThreadReadResponse {
|
||||
pub thread: Thread,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadTurnsListParams {
|
||||
pub thread_id: String,
|
||||
/// Opaque cursor to pass to the next call to continue after the last turn.
|
||||
#[ts(optional = nullable)]
|
||||
pub cursor: Option<String>,
|
||||
/// Optional turn page size.
|
||||
#[ts(optional = nullable)]
|
||||
pub limit: Option<u32>,
|
||||
/// Optional turn pagination direction; defaults to descending.
|
||||
#[ts(optional = nullable)]
|
||||
pub sort_direction: Option<SortDirection>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadTurnsListResponse {
|
||||
pub data: Vec<Turn>,
|
||||
/// Opaque cursor to pass to the next call to continue after the last turn.
|
||||
/// if None, there are no more turns to return.
|
||||
pub next_cursor: Option<String>,
|
||||
/// Opaque cursor to pass as `cursor` when reversing `sortDirection`.
|
||||
/// This is only populated when the page contains at least one turn.
|
||||
/// Use it with the opposite `sortDirection` to include the anchor turn again
|
||||
/// and catch updates to that turn.
|
||||
pub backwards_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -3692,6 +3740,14 @@ pub enum PluginSource {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Local { path: AbsolutePathBuf },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Git {
|
||||
url: String,
|
||||
path: Option<String>,
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -6530,6 +6586,7 @@ pub struct RateLimitSnapshot {
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
pub plan_type: Option<PlanType>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
|
||||
impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
@@ -6541,6 +6598,60 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
|
||||
secondary: value.secondary.map(RateLimitWindow::from),
|
||||
credits: value.credits.map(CreditsSnapshot::from),
|
||||
plan_type: value.plan_type,
|
||||
rate_limit_reached_type: value
|
||||
.rate_limit_reached_type
|
||||
.map(RateLimitReachedType::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export_to = "v2/", rename_all = "snake_case")]
|
||||
pub enum RateLimitReachedType {
|
||||
RateLimitReached,
|
||||
WorkspaceOwnerCreditsDepleted,
|
||||
WorkspaceMemberCreditsDepleted,
|
||||
WorkspaceOwnerUsageLimitReached,
|
||||
WorkspaceMemberUsageLimitReached,
|
||||
}
|
||||
|
||||
impl From<CoreRateLimitReachedType> for RateLimitReachedType {
|
||||
fn from(value: CoreRateLimitReachedType) -> Self {
|
||||
match value {
|
||||
CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached,
|
||||
CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
|
||||
Self::WorkspaceOwnerCreditsDepleted
|
||||
}
|
||||
CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => {
|
||||
Self::WorkspaceMemberCreditsDepleted
|
||||
}
|
||||
CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => {
|
||||
Self::WorkspaceOwnerUsageLimitReached
|
||||
}
|
||||
CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => {
|
||||
Self::WorkspaceMemberUsageLimitReached
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RateLimitReachedType> for CoreRateLimitReachedType {
|
||||
fn from(value: RateLimitReachedType) -> Self {
|
||||
match value {
|
||||
RateLimitReachedType::RateLimitReached => Self::RateLimitReached,
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
|
||||
Self::WorkspaceOwnerCreditsDepleted
|
||||
}
|
||||
RateLimitReachedType::WorkspaceMemberCreditsDepleted => {
|
||||
Self::WorkspaceMemberCreditsDepleted
|
||||
}
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached => {
|
||||
Self::WorkspaceOwnerUsageLimitReached
|
||||
}
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached => {
|
||||
Self::WorkspaceMemberUsageLimitReached
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1124,6 +1124,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3
|
||||
cursor: None,
|
||||
limit: Some(limit),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
|
||||
@@ -142,6 +142,7 @@ Example with notification opt-out:
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/turns/list` — page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`.
|
||||
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
|
||||
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
|
||||
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
|
||||
@@ -281,11 +282,13 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per
|
||||
- `cursor` — opaque string from a prior response; omit for the first page.
|
||||
- `limit` — server defaults to a reasonable page size if unset.
|
||||
- `sortKey` — `created_at` (default) or `updated_at`.
|
||||
- `sortDirection` — `desc` (default) or `asc`.
|
||||
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
|
||||
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
|
||||
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
|
||||
- `cwd` — restrict results to threads whose session cwd exactly matches this path. Relative paths are resolved against the app-server process cwd before matching.
|
||||
- `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive).
|
||||
- Responses include `nextCursor` to continue in the same direction and `backwardsCursor` to pass as `cursor` when reversing `sortDirection`.
|
||||
- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
|
||||
|
||||
Example:
|
||||
@@ -301,7 +304,8 @@ Example:
|
||||
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" },
|
||||
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } }
|
||||
],
|
||||
"nextCursor": "opaque-token-or-null"
|
||||
"nextCursor": "opaque-token-or-null",
|
||||
"backwardsCursor": "opaque-token-or-null"
|
||||
} }
|
||||
```
|
||||
|
||||
@@ -363,7 +367,7 @@ Later, after the idle unload timeout:
|
||||
|
||||
### Example: Read a thread
|
||||
|
||||
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
|
||||
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the full rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
|
||||
|
||||
```json
|
||||
{ "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } }
|
||||
@@ -379,6 +383,23 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl
|
||||
} }
|
||||
```
|
||||
|
||||
### Example: List thread turns
|
||||
|
||||
Use `thread/turns/list` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page.
|
||||
|
||||
```json
|
||||
{ "method": "thread/turns/list", "id": 24, "params": {
|
||||
"threadId": "thr_123",
|
||||
"limit": 50,
|
||||
"sortDirection": "desc"
|
||||
} }
|
||||
{ "id": 24, "result": {
|
||||
"data": [ ... ],
|
||||
"nextCursor": "older-turns-cursor-or-null",
|
||||
"backwardsCursor": "newer-turns-cursor-or-null"
|
||||
} }
|
||||
```
|
||||
|
||||
### Example: Update stored thread metadata
|
||||
|
||||
Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value.
|
||||
|
||||
@@ -2017,9 +2017,12 @@ async fn complete_file_change_item(
|
||||
outgoing: &ThreadScopedOutgoingMessageSender,
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
) {
|
||||
let mut state = thread_state.lock().await;
|
||||
state.turn_summary.file_change_started.remove(&item_id);
|
||||
drop(state);
|
||||
thread_state
|
||||
.lock()
|
||||
.await
|
||||
.turn_summary
|
||||
.file_change_started
|
||||
.remove(&item_id);
|
||||
|
||||
let notification = ItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
@@ -2088,12 +2091,12 @@ async fn complete_command_execution_item(
|
||||
outgoing: &ThreadScopedOutgoingMessageSender,
|
||||
thread_state: &Arc<Mutex<ThreadState>>,
|
||||
) {
|
||||
let mut state = thread_state.lock().await;
|
||||
let should_emit = state
|
||||
let should_emit = thread_state
|
||||
.lock()
|
||||
.await
|
||||
.turn_summary
|
||||
.command_execution_started
|
||||
.remove(&item_id);
|
||||
drop(state);
|
||||
if !should_emit {
|
||||
return;
|
||||
}
|
||||
@@ -4139,6 +4142,7 @@ mod tests {
|
||||
balance: Some("5".to_string()),
|
||||
}),
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
handle_token_count_event(
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
|
||||
use crate::thread_status::ThreadWatchManager;
|
||||
use crate::thread_status::resolve_thread_status;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
@@ -123,6 +124,7 @@ use codex_app_server_protocol::SkillsConfigWriteParams;
|
||||
use codex_app_server_protocol::SkillsConfigWriteResponse;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::SortDirection;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadArchiveResponse;
|
||||
@@ -177,6 +179,8 @@ use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::ThreadStartedNotification;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_app_server_protocol::ThreadTurnsListParams;
|
||||
use codex_app_server_protocol::ThreadTurnsListResponse;
|
||||
use codex_app_server_protocol::ThreadUnarchiveParams;
|
||||
use codex_app_server_protocol::ThreadUnarchiveResponse;
|
||||
use codex_app_server_protocol::ThreadUnarchivedNotification;
|
||||
@@ -325,6 +329,7 @@ use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
|
||||
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::ReadThreadParams as StoreReadThreadParams;
|
||||
use codex_thread_store::SortDirection as StoreSortDirection;
|
||||
use codex_thread_store::StoredThread;
|
||||
use codex_thread_store::ThreadSortKey as StoreThreadSortKey;
|
||||
use codex_thread_store::ThreadStore;
|
||||
@@ -377,6 +382,8 @@ use token_usage_replay::send_thread_token_usage_update_to_connection;
|
||||
|
||||
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
|
||||
const THREAD_LIST_MAX_LIMIT: usize = 100;
|
||||
const THREAD_TURNS_DEFAULT_LIMIT: usize = 25;
|
||||
const THREAD_TURNS_MAX_LIMIT: usize = 100;
|
||||
|
||||
struct ThreadListFilters {
|
||||
model_providers: Option<Vec<String>>,
|
||||
@@ -951,6 +958,10 @@ impl CodexMessageProcessor {
|
||||
self.thread_read(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ThreadTurnsList { request_id, params } => {
|
||||
self.thread_turns_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ThreadShellCommand { request_id, params } => {
|
||||
self.thread_shell_command(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -3510,9 +3521,7 @@ impl CodexMessageProcessor {
|
||||
// No ThreadRollback event will arrive if an error occurs.
|
||||
// Clean up and reply immediately.
|
||||
let thread_state = self.thread_state_manager.thread_state(thread_id).await;
|
||||
let mut thread_state = thread_state.lock().await;
|
||||
thread_state.pending_rollbacks = None;
|
||||
drop(thread_state);
|
||||
thread_state.lock().await.pending_rollbacks = None;
|
||||
|
||||
self.send_internal_error(request, format!("failed to start rollback: {err}"))
|
||||
.await;
|
||||
@@ -3641,6 +3650,7 @@ impl CodexMessageProcessor {
|
||||
cursor,
|
||||
limit,
|
||||
sort_key,
|
||||
sort_direction,
|
||||
model_providers,
|
||||
source_kinds,
|
||||
archived,
|
||||
@@ -3663,11 +3673,13 @@ impl CodexMessageProcessor {
|
||||
ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt,
|
||||
ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt,
|
||||
};
|
||||
let (summaries, next_cursor) = match self
|
||||
let sort_direction = sort_direction.unwrap_or(SortDirection::Desc);
|
||||
let list_result = self
|
||||
.list_threads_common(
|
||||
requested_page_size,
|
||||
cursor,
|
||||
store_sort_key,
|
||||
sort_direction,
|
||||
ThreadListFilters {
|
||||
model_providers,
|
||||
source_kinds,
|
||||
@@ -3676,14 +3688,17 @@ impl CodexMessageProcessor {
|
||||
search_term,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
let (summaries, next_cursor) = match list_result {
|
||||
Ok(r) => r,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let backwards_cursor = summaries.first().and_then(|summary| {
|
||||
thread_backwards_cursor_for_sort_key(summary, store_sort_key, sort_direction)
|
||||
});
|
||||
let mut threads = Vec::with_capacity(summaries.len());
|
||||
let mut thread_ids = HashSet::with_capacity(summaries.len());
|
||||
let mut status_ids = Vec::with_capacity(summaries.len());
|
||||
@@ -3704,7 +3719,7 @@ impl CodexMessageProcessor {
|
||||
.loaded_statuses_for_threads(status_ids)
|
||||
.await;
|
||||
|
||||
let data = threads
|
||||
let data: Vec<_> = threads
|
||||
.into_iter()
|
||||
.map(|(conversation_id, mut thread)| {
|
||||
if let Some(title) = names.get(&conversation_id).cloned() {
|
||||
@@ -3716,7 +3731,11 @@ impl CodexMessageProcessor {
|
||||
thread
|
||||
})
|
||||
.collect();
|
||||
let response = ThreadListResponse { data, next_cursor };
|
||||
let response = ThreadListResponse {
|
||||
data,
|
||||
next_cursor,
|
||||
backwards_cursor,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
@@ -3832,7 +3851,7 @@ impl CodexMessageProcessor {
|
||||
)));
|
||||
};
|
||||
|
||||
let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread {
|
||||
let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() {
|
||||
matches!(loaded_thread.agent_status().await, AgentStatus::Running)
|
||||
} else {
|
||||
false
|
||||
@@ -3959,6 +3978,157 @@ impl CodexMessageProcessor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn thread_turns_list(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: ThreadTurnsListParams,
|
||||
) {
|
||||
let ThreadTurnsListParams {
|
||||
thread_id,
|
||||
cursor,
|
||||
limit,
|
||||
sort_direction,
|
||||
} = params;
|
||||
|
||||
let thread_uuid = match ThreadId::from_string(&thread_id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
self.send_invalid_request_error(request_id, format!("invalid thread id: {err}"))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let state_db_ctx = get_state_db(&self.config).await;
|
||||
let mut rollout_path = self
|
||||
.resolve_rollout_path(thread_uuid, state_db_ctx.as_ref())
|
||||
.await;
|
||||
if rollout_path.is_none() {
|
||||
rollout_path =
|
||||
match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string())
|
||||
.await
|
||||
{
|
||||
Ok(Some(path)) => Some(path),
|
||||
Ok(None) => match find_archived_thread_path_by_id_str(
|
||||
&self.config.codex_home,
|
||||
&thread_uuid.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
format!("failed to locate archived thread id {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
format!("failed to locate thread id {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if rollout_path.is_none() {
|
||||
match self.thread_manager.get_thread(thread_uuid).await {
|
||||
Ok(thread) => {
|
||||
rollout_path = thread.rollout_path();
|
||||
if rollout_path.is_none() {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"ephemeral threads do not support thread/turns/list".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
format!("thread not loaded: {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(rollout_path) = rollout_path.as_ref() else {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to locate rollout for thread {thread_uuid}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
match read_rollout_items_from_rollout(rollout_path).await {
|
||||
Ok(items) => {
|
||||
// This API optimizes network transfer by letting clients page through a
|
||||
// thread's turns incrementally, but it still replays the entire rollout on
|
||||
// every request. Rollback and compaction events can change earlier turns, so
|
||||
// the server has to rebuild the full turn list until turn metadata is indexed
|
||||
// separately.
|
||||
let has_live_in_progress_turn =
|
||||
match self.thread_manager.get_thread(thread_uuid).await {
|
||||
Ok(thread) => matches!(thread.agent_status().await, AgentStatus::Running),
|
||||
Err(_) => false,
|
||||
};
|
||||
let turns = reconstruct_thread_turns_from_rollout_items(
|
||||
&items,
|
||||
self.thread_watch_manager
|
||||
.loaded_status_for_thread(&thread_uuid.to_string())
|
||||
.await,
|
||||
has_live_in_progress_turn,
|
||||
);
|
||||
let page = match paginate_thread_turns(
|
||||
turns,
|
||||
cursor.as_deref(),
|
||||
limit,
|
||||
sort_direction.unwrap_or(SortDirection::Desc),
|
||||
) {
|
||||
Ok(page) => page,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let response = ThreadTurnsListResponse {
|
||||
data: page.turns,
|
||||
next_cursor: page.next_cursor,
|
||||
backwards_cursor: page.backwards_cursor,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
format!(
|
||||
"thread {thread_uuid} is not materialized yet; thread/turns/list is unavailable before first user message"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!(
|
||||
"failed to load rollout `{}` for thread {thread_uuid}: {err}",
|
||||
rollout_path.display()
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver<ThreadId> {
|
||||
self.thread_manager.subscribe_thread_created()
|
||||
}
|
||||
@@ -4980,6 +5150,7 @@ impl CodexMessageProcessor {
|
||||
requested_page_size: usize,
|
||||
cursor: Option<String>,
|
||||
sort_key: StoreThreadSortKey,
|
||||
sort_direction: SortDirection,
|
||||
filters: ThreadListFilters,
|
||||
) -> Result<(Vec<ConversationSummary>, Option<String>), JSONRPCErrorError> {
|
||||
let ThreadListFilters {
|
||||
@@ -5008,6 +5179,10 @@ impl CodexMessageProcessor {
|
||||
let fallback_provider = self.config.model_provider_id.clone();
|
||||
let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds);
|
||||
let allowed_sources = allowed_sources_vec.as_slice();
|
||||
let store_sort_direction = match sort_direction {
|
||||
SortDirection::Asc => StoreSortDirection::Asc,
|
||||
SortDirection::Desc => StoreSortDirection::Desc,
|
||||
};
|
||||
|
||||
while remaining > 0 {
|
||||
let page_size = remaining.min(THREAD_LIST_MAX_LIMIT);
|
||||
@@ -5017,6 +5192,7 @@ impl CodexMessageProcessor {
|
||||
page_size,
|
||||
cursor: cursor_obj.clone(),
|
||||
sort_key,
|
||||
sort_direction: store_sort_direction,
|
||||
allowed_sources: allowed_sources.to_vec(),
|
||||
model_providers: model_provider_filter.clone(),
|
||||
archived,
|
||||
@@ -5025,12 +5201,17 @@ impl CodexMessageProcessor {
|
||||
.await
|
||||
.map_err(thread_store_list_error)?;
|
||||
|
||||
let mut filtered = Vec::with_capacity(page.items.len());
|
||||
let mut candidate_summaries = Vec::with_capacity(page.items.len());
|
||||
for it in page.items {
|
||||
let Some(summary) = summary_from_stored_thread(it, fallback_provider.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
candidate_summaries.push(summary);
|
||||
}
|
||||
|
||||
let mut filtered = Vec::with_capacity(candidate_summaries.len());
|
||||
for summary in candidate_summaries {
|
||||
if source_kind_filter
|
||||
.as_ref()
|
||||
.is_none_or(|filter| source_kind_matches(&summary.source, filter))
|
||||
@@ -8051,8 +8232,10 @@ impl CodexMessageProcessor {
|
||||
start_fuzzy_file_search_session(session_id.clone(), roots, self.outgoing.clone());
|
||||
match session {
|
||||
Ok(session) => {
|
||||
let mut sessions = self.fuzzy_search_sessions.lock().await;
|
||||
sessions.insert(session_id, session);
|
||||
self.fuzzy_search_sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id, session);
|
||||
self.outgoing
|
||||
.send_response(request_id, FuzzyFileSearchSessionStartResponse {})
|
||||
.await;
|
||||
@@ -8944,6 +9127,17 @@ fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterfa
|
||||
fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource {
|
||||
match source {
|
||||
MarketplacePluginSource::Local { path } => PluginSource::Local { path },
|
||||
MarketplacePluginSource::Git {
|
||||
url,
|
||||
path,
|
||||
ref_name,
|
||||
sha,
|
||||
} => PluginSource::Git {
|
||||
url,
|
||||
path,
|
||||
ref_name,
|
||||
sha,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9339,8 +9533,18 @@ fn summary_from_stored_thread(
|
||||
conversation_id: thread.thread_id,
|
||||
path,
|
||||
preview: thread.first_user_message.unwrap_or(thread.preview),
|
||||
timestamp: Some(thread.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
||||
updated_at: Some(thread.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
||||
// Preserve millisecond precision from the thread store so thread/list cursors
|
||||
// round-trip the same ordering key used by pagination queries.
|
||||
timestamp: Some(
|
||||
thread
|
||||
.created_at
|
||||
.to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
),
|
||||
updated_at: Some(
|
||||
thread
|
||||
.updated_at
|
||||
.to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||
),
|
||||
model_provider: if thread.model_provider.is_empty() {
|
||||
fallback_provider.to_string()
|
||||
} else {
|
||||
@@ -9764,17 +9968,188 @@ pub(crate) fn summary_to_thread(
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_backwards_cursor_for_sort_key(
|
||||
summary: &ConversationSummary,
|
||||
sort_key: StoreThreadSortKey,
|
||||
sort_direction: SortDirection,
|
||||
) -> Option<String> {
|
||||
let timestamp = match sort_key {
|
||||
StoreThreadSortKey::CreatedAt => summary.timestamp.as_deref(),
|
||||
StoreThreadSortKey::UpdatedAt => summary
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(summary.timestamp.as_deref()),
|
||||
};
|
||||
let timestamp = parse_datetime(timestamp)?;
|
||||
// The state DB stores unique millisecond timestamps. Offset the reverse cursor by one
|
||||
// millisecond so the opposite-direction query includes the page anchor.
|
||||
let timestamp = match sort_direction {
|
||||
SortDirection::Asc => timestamp.checked_add_signed(ChronoDuration::milliseconds(1))?,
|
||||
SortDirection::Desc => timestamp.checked_sub_signed(ChronoDuration::milliseconds(1))?,
|
||||
};
|
||||
Some(timestamp.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
}
|
||||
|
||||
struct ThreadTurnsPage {
|
||||
turns: Vec<Turn>,
|
||||
next_cursor: Option<String>,
|
||||
backwards_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ThreadTurnsCursor {
|
||||
turn_id: String,
|
||||
include_anchor: bool,
|
||||
}
|
||||
|
||||
fn paginate_thread_turns(
|
||||
turns: Vec<Turn>,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
sort_direction: SortDirection,
|
||||
) -> Result<ThreadTurnsPage, JSONRPCErrorError> {
|
||||
if turns.is_empty() {
|
||||
return Ok(ThreadTurnsPage {
|
||||
turns: Vec::new(),
|
||||
next_cursor: None,
|
||||
backwards_cursor: None,
|
||||
});
|
||||
}
|
||||
|
||||
let anchor = cursor.map(parse_thread_turns_cursor).transpose()?;
|
||||
let page_size = limit
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(THREAD_TURNS_DEFAULT_LIMIT)
|
||||
.clamp(1, THREAD_TURNS_MAX_LIMIT);
|
||||
|
||||
let anchor_index = anchor
|
||||
.as_ref()
|
||||
.and_then(|anchor| turns.iter().position(|turn| turn.id == anchor.turn_id));
|
||||
if anchor.is_some() && anchor_index.is_none() {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "invalid cursor: anchor turn is no longer present".to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut keyed_turns: Vec<_> = turns.into_iter().enumerate().collect();
|
||||
match sort_direction {
|
||||
SortDirection::Asc => {
|
||||
if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) {
|
||||
keyed_turns.retain(|(index, _)| {
|
||||
if anchor.include_anchor {
|
||||
*index >= anchor_index
|
||||
} else {
|
||||
*index > anchor_index
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
SortDirection::Desc => {
|
||||
keyed_turns.reverse();
|
||||
if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) {
|
||||
keyed_turns.retain(|(index, _)| {
|
||||
if anchor.include_anchor {
|
||||
*index <= anchor_index
|
||||
} else {
|
||||
*index < anchor_index
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let more_turns_available = keyed_turns.len() > page_size;
|
||||
keyed_turns.truncate(page_size);
|
||||
let backwards_cursor = keyed_turns
|
||||
.first()
|
||||
.map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ true))
|
||||
.transpose()?;
|
||||
let next_cursor = if more_turns_available {
|
||||
keyed_turns
|
||||
.last()
|
||||
.map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ false))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let turns = keyed_turns.into_iter().map(|(_, turn)| turn).collect();
|
||||
|
||||
Ok(ThreadTurnsPage {
|
||||
turns,
|
||||
next_cursor,
|
||||
backwards_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize_thread_turns_cursor(
|
||||
turn_id: &str,
|
||||
include_anchor: bool,
|
||||
) -> Result<String, JSONRPCErrorError> {
|
||||
serde_json::to_string(&ThreadTurnsCursor {
|
||||
turn_id: turn_id.to_string(),
|
||||
include_anchor,
|
||||
})
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to serialize cursor: {err}"),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_thread_turns_cursor(cursor: &str) -> Result<ThreadTurnsCursor, JSONRPCErrorError> {
|
||||
serde_json::from_str(cursor).map_err(|_| JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid cursor: {cursor}"),
|
||||
data: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn reconstruct_thread_turns_from_rollout_items(
|
||||
items: &[RolloutItem],
|
||||
loaded_status: ThreadStatus,
|
||||
has_live_in_progress_turn: bool,
|
||||
) -> Vec<Turn> {
|
||||
let mut turns = build_turns_from_rollout_items(items);
|
||||
normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn);
|
||||
turns
|
||||
}
|
||||
|
||||
fn normalize_thread_turns_status(
|
||||
turns: &mut [Turn],
|
||||
loaded_status: ThreadStatus,
|
||||
has_live_in_progress_turn: bool,
|
||||
) {
|
||||
let status = resolve_thread_status(loaded_status, has_live_in_progress_turn);
|
||||
if matches!(status, ThreadStatus::Active { .. }) {
|
||||
return;
|
||||
}
|
||||
for turn in turns {
|
||||
if matches!(turn.status, TurnStatus::InProgress) {
|
||||
turn.status = TurnStatus::Interrupted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use anyhow::Result;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_thread_store::StoredThread;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -9824,6 +10199,53 @@ mod tests {
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_from_stored_thread_preserves_millisecond_precision() {
|
||||
let created_at =
|
||||
DateTime::parse_from_rfc3339("2025-01-02T03:04:05.678Z").expect("valid timestamp");
|
||||
let updated_at =
|
||||
DateTime::parse_from_rfc3339("2025-01-02T03:04:06.789Z").expect("valid timestamp");
|
||||
let thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread");
|
||||
let stored_thread = StoredThread {
|
||||
thread_id,
|
||||
rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")),
|
||||
forked_from_id: None,
|
||||
preview: "preview".to_string(),
|
||||
name: None,
|
||||
model_provider: "openai".to_string(),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
created_at: created_at.with_timezone(&Utc),
|
||||
updated_at: updated_at.with_timezone(&Utc),
|
||||
archived_at: None,
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
agent_path: None,
|
||||
git_info: None,
|
||||
approval_mode: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
token_usage: None,
|
||||
first_user_message: Some("first user message".to_string()),
|
||||
history: None,
|
||||
};
|
||||
|
||||
let summary =
|
||||
summary_from_stored_thread(stored_thread, "fallback").expect("summary should exist");
|
||||
|
||||
assert_eq!(
|
||||
summary.timestamp.as_deref(),
|
||||
Some("2025-01-02T03:04:05.678Z")
|
||||
);
|
||||
assert_eq!(
|
||||
summary.updated_at.as_deref(),
|
||||
Some("2025-01-02T03:04:06.789Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_load_error_marks_cloud_requirements_failures_for_relogin() {
|
||||
let err = std::io::Error::other(CloudRequirementsLoadError::new(
|
||||
@@ -10446,13 +10868,9 @@ mod tests {
|
||||
assert_eq!(cancel_rx.await, Ok(()));
|
||||
|
||||
let state = manager.thread_state(thread_id).await;
|
||||
let subscribed_connection_ids = manager.subscribed_connection_ids(thread_id).await;
|
||||
assert!(subscribed_connection_ids.is_empty());
|
||||
let state = state.lock().await;
|
||||
assert!(
|
||||
manager
|
||||
.subscribed_connection_ids(thread_id)
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
assert!(state.cancel_tx.is_none());
|
||||
assert!(state.active_turn_snapshot().is_none());
|
||||
Ok(())
|
||||
|
||||
@@ -736,6 +736,7 @@ mod tests {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -754,7 +755,8 @@ mod tests {
|
||||
},
|
||||
"secondary": null,
|
||||
"credits": null,
|
||||
"planType": "plus"
|
||||
"planType": "plus",
|
||||
"rateLimitReachedType": null
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -392,7 +392,14 @@ impl RemoteControlWebsocket {
|
||||
ping_interval: std::time::Duration,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> io::Result<()> {
|
||||
for server_envelope in state.lock().await.outbound_buffer.server_envelopes() {
|
||||
let server_envelopes = state
|
||||
.lock()
|
||||
.await
|
||||
.outbound_buffer
|
||||
.server_envelopes()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for server_envelope in server_envelopes {
|
||||
let payload = match serde_json::to_string(&server_envelope) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
@@ -594,21 +601,22 @@ impl RemoteControlWebsocket {
|
||||
}
|
||||
};
|
||||
|
||||
let mut websocket_state = state.lock().await;
|
||||
if let Some(cursor) = client_envelope.cursor.as_deref() {
|
||||
websocket_state.subscribe_cursor = Some(cursor.to_string());
|
||||
}
|
||||
if let ClientEvent::Ack = &client_envelope.event
|
||||
&& let Some(acked_seq_id) = client_envelope.seq_id
|
||||
&& let Some(stream_id) = client_envelope.stream_id.as_ref()
|
||||
{
|
||||
websocket_state.outbound_buffer.ack(
|
||||
&client_envelope.client_id,
|
||||
stream_id,
|
||||
acked_seq_id,
|
||||
);
|
||||
let mut websocket_state = state.lock().await;
|
||||
if let Some(cursor) = client_envelope.cursor.as_deref() {
|
||||
websocket_state.subscribe_cursor = Some(cursor.to_string());
|
||||
}
|
||||
if let ClientEvent::Ack = &client_envelope.event
|
||||
&& let Some(acked_seq_id) = client_envelope.seq_id
|
||||
&& let Some(stream_id) = client_envelope.stream_id.as_ref()
|
||||
{
|
||||
websocket_state.outbound_buffer.ack(
|
||||
&client_envelope.client_id,
|
||||
stream_id,
|
||||
acked_seq_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(websocket_state);
|
||||
|
||||
if client_tracker
|
||||
.handle_message(client_envelope)
|
||||
|
||||
@@ -79,6 +79,7 @@ use codex_app_server_protocol::ThreadRollbackParams;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadShellCommandParams;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadTurnsListParams;
|
||||
use codex_app_server_protocol::ThreadUnarchiveParams;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
@@ -464,6 +465,15 @@ impl McpProcess {
|
||||
self.send_request("thread/read", params).await
|
||||
}
|
||||
|
||||
/// Send a `thread/turns/list` JSON-RPC request.
|
||||
pub async fn send_thread_turns_list_request(
|
||||
&mut self,
|
||||
params: ThreadTurnsListParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("thread/turns/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `model/list` JSON-RPC request.
|
||||
pub async fn send_list_models_request(
|
||||
&mut self,
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::RateLimitReachedType;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -118,6 +119,9 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
"reset_at": secondary_reset_timestamp,
|
||||
}
|
||||
},
|
||||
"rate_limit_reached_type": {
|
||||
"type": "workspace_member_usage_limit_reached",
|
||||
},
|
||||
"additional_rate_limits": [
|
||||
{
|
||||
"limit_name": "codex_other",
|
||||
@@ -173,6 +177,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
}),
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
},
|
||||
rate_limits_by_limit_id: Some(
|
||||
[
|
||||
@@ -193,6 +198,9 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
}),
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: Some(
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -208,6 +216,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -543,6 +543,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
|
||||
@@ -14,6 +14,7 @@ use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SessionSource;
|
||||
use codex_app_server_protocol::SortDirection;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadSortKey;
|
||||
use codex_app_server_protocol::ThreadSourceKind;
|
||||
@@ -84,6 +85,7 @@ async fn list_threads_with_sort(
|
||||
cursor,
|
||||
limit,
|
||||
sort_key,
|
||||
sort_direction: None,
|
||||
model_providers: providers,
|
||||
source_kinds,
|
||||
archived,
|
||||
@@ -357,6 +359,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
|
||||
let ThreadListResponse {
|
||||
data: data1,
|
||||
next_cursor: cursor1,
|
||||
..
|
||||
} = list_threads(
|
||||
&mut mcp,
|
||||
/*cursor*/ None,
|
||||
@@ -384,6 +387,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
|
||||
let ThreadListResponse {
|
||||
data: data2,
|
||||
next_cursor: cursor2,
|
||||
..
|
||||
} = list_threads(
|
||||
&mut mcp,
|
||||
Some(cursor1),
|
||||
@@ -498,6 +502,7 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
@@ -584,6 +589,7 @@ sqlite = true
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
codex_core::ThreadSortKey::CreatedAt,
|
||||
codex_core::SortDirection::Desc,
|
||||
&[],
|
||||
/*model_providers*/ None,
|
||||
"mock_provider",
|
||||
@@ -598,6 +604,7 @@ sqlite = true
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
@@ -1252,6 +1259,111 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_minimal_config(codex_home.path())?;
|
||||
|
||||
let id_old = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-02-01T10-00-00",
|
||||
"2025-02-01T10:00:00Z",
|
||||
"Hello",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let id_watermark = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-02-01T11-00-00",
|
||||
"2025-02-01T11:00:00Z",
|
||||
"Hello",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
|
||||
set_rollout_mtime(
|
||||
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_old).as_path(),
|
||||
"2025-02-02T00:00:00Z",
|
||||
)?;
|
||||
set_rollout_mtime(
|
||||
rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_watermark).as_path(),
|
||||
"2025-02-03T00:00:00Z",
|
||||
)?;
|
||||
|
||||
let mut mcp = init_mcp(codex_home.path()).await?;
|
||||
|
||||
let ThreadListResponse {
|
||||
data: page1,
|
||||
backwards_cursor,
|
||||
..
|
||||
} = {
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
|
||||
cursor: None,
|
||||
limit: Some(1),
|
||||
sort_key: Some(ThreadSortKey::UpdatedAt),
|
||||
sort_direction: Some(SortDirection::Desc),
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
search_term: None,
|
||||
})
|
||||
.await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response::<ThreadListResponse>(resp)?
|
||||
};
|
||||
let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect();
|
||||
assert_eq!(ids_page1, vec![id_watermark.as_str()]);
|
||||
let backwards_cursor = backwards_cursor.expect("expected backwardsCursor on first page");
|
||||
assert_eq!(backwards_cursor, "2025-02-02T23:59:59.999Z");
|
||||
|
||||
let id_new = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-02-01T12-00-00",
|
||||
"2025-02-01T12:00:00Z",
|
||||
"Hello",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
set_rollout_mtime(
|
||||
rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_new).as_path(),
|
||||
"2025-02-04T00:00:00Z",
|
||||
)?;
|
||||
|
||||
let ThreadListResponse {
|
||||
data: delta_page, ..
|
||||
} = {
|
||||
let request_id = mcp
|
||||
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
|
||||
cursor: Some(backwards_cursor),
|
||||
limit: Some(10),
|
||||
sort_key: Some(ThreadSortKey::UpdatedAt),
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
search_term: None,
|
||||
})
|
||||
.await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response::<ThreadListResponse>(resp)?
|
||||
};
|
||||
let ids_delta: Vec<_> = delta_page.iter().map(|thread| thread.id.as_str()).collect();
|
||||
assert_eq!(ids_delta, vec![id_watermark.as_str(), id_new.as_str()]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1468,6 +1580,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
|
||||
cursor: Some("not-a-cursor".to_string()),
|
||||
limit: Some(2),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
|
||||
@@ -109,7 +109,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread: read } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread: read, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(
|
||||
read.git_info,
|
||||
@@ -421,7 +421,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> {
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread: read } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread: read, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(read.git_info, None);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SessionSource;
|
||||
use codex_app_server_protocol::SortDirection;
|
||||
use codex_app_server_protocol::ThreadForkParams;
|
||||
use codex_app_server_protocol::ThreadForkResponse;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
@@ -24,6 +25,8 @@ use codex_app_server_protocol::ThreadSetNameResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_app_server_protocol::ThreadTurnsListParams;
|
||||
use codex_app_server_protocol::ThreadTurnsListResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
@@ -34,6 +37,8 @@ use codex_protocol::user_input::TextElement;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
@@ -81,7 +86,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> {
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(thread.id, conversation_id);
|
||||
assert_eq!(thread.preview, preview);
|
||||
@@ -136,7 +141,7 @@ async fn thread_read_can_include_turns() -> Result<()> {
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(thread.turns.len(), 1);
|
||||
let turn = &thread.turns[0];
|
||||
@@ -159,6 +164,88 @@ async fn thread_read_can_include_turns() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let filename_ts = "2025-01-05T12-00-00";
|
||||
let conversation_id = create_fake_rollout_with_text_elements(
|
||||
codex_home.path(),
|
||||
filename_ts,
|
||||
"2025-01-05T12:00:00Z",
|
||||
"first",
|
||||
vec![],
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
||||
append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?;
|
||||
append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_turns_list_request(ThreadTurnsListParams {
|
||||
thread_id: conversation_id.clone(),
|
||||
cursor: None,
|
||||
limit: Some(2),
|
||||
sort_direction: Some(SortDirection::Desc),
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadTurnsListResponse {
|
||||
data,
|
||||
next_cursor,
|
||||
backwards_cursor,
|
||||
} = to_response::<ThreadTurnsListResponse>(read_resp)?;
|
||||
assert_eq!(turn_user_texts(&data), vec!["third", "second"]);
|
||||
let next_cursor = next_cursor.expect("expected nextCursor for older turns");
|
||||
let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn");
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_turns_list_request(ThreadTurnsListParams {
|
||||
thread_id: conversation_id.clone(),
|
||||
cursor: Some(next_cursor),
|
||||
limit: Some(10),
|
||||
sort_direction: Some(SortDirection::Desc),
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadTurnsListResponse { data, .. } = to_response::<ThreadTurnsListResponse>(read_resp)?;
|
||||
assert_eq!(turn_user_texts(&data), vec!["first"]);
|
||||
|
||||
append_user_message(rollout_path.as_path(), "2025-01-05T12:03:00Z", "fourth")?;
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_turns_list_request(ThreadTurnsListParams {
|
||||
thread_id: conversation_id,
|
||||
cursor: Some(backwards_cursor),
|
||||
limit: Some(10),
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadTurnsListResponse { data, .. } = to_response::<ThreadTurnsListResponse>(read_resp)?;
|
||||
assert_eq!(turn_user_texts(&data), vec!["third", "fourth"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_read_can_return_archived_threads_by_id() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -207,6 +294,75 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let filename_ts = "2025-01-05T12-00-00";
|
||||
let conversation_id = create_fake_rollout_with_text_elements(
|
||||
codex_home.path(),
|
||||
filename_ts,
|
||||
"2025-01-05T12:00:00Z",
|
||||
"first",
|
||||
vec![],
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
|
||||
append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?;
|
||||
append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_turns_list_request(ThreadTurnsListParams {
|
||||
thread_id: conversation_id.clone(),
|
||||
cursor: None,
|
||||
limit: Some(2),
|
||||
sort_direction: Some(SortDirection::Desc),
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadTurnsListResponse {
|
||||
backwards_cursor, ..
|
||||
} = to_response::<ThreadTurnsListResponse>(read_resp)?;
|
||||
let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn");
|
||||
|
||||
append_thread_rollback(
|
||||
rollout_path.as_path(),
|
||||
"2025-01-05T12:03:00Z",
|
||||
/*num_turns*/ 1,
|
||||
)?;
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_turns_list_request(ThreadTurnsListParams {
|
||||
thread_id: conversation_id,
|
||||
cursor: Some(backwards_cursor),
|
||||
limit: Some(10),
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
})
|
||||
.await?;
|
||||
let read_err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(
|
||||
read_err.error.message,
|
||||
"invalid cursor: anchor turn is no longer present"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -250,7 +406,7 @@ async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> {
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(thread.forked_from_id, Some(conversation_id));
|
||||
|
||||
@@ -295,7 +451,7 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread: read } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread: read, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(read.id, thread.id);
|
||||
assert_eq!(read.path, Some(thread_path));
|
||||
@@ -363,7 +519,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
|
||||
)
|
||||
.await??;
|
||||
let read_result = read_resp.result.clone();
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
assert_eq!(thread.id, conversation_id);
|
||||
assert_eq!(thread.name.as_deref(), Some(new_name));
|
||||
let thread_json = read_result
|
||||
@@ -387,6 +543,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
|
||||
cursor: None,
|
||||
limit: Some(50),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: Some(vec!["mock_provider".to_string()]),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
@@ -572,13 +729,63 @@ async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Resul
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(thread.status, ThreadStatus::SystemError,);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_user_message(path: &Path, timestamp: &str, text: &str) -> std::io::Result<()> {
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
json!({
|
||||
"timestamp": timestamp,
|
||||
"type":"event_msg",
|
||||
"payload": {
|
||||
"type":"user_message",
|
||||
"message": text,
|
||||
"text_elements": [],
|
||||
"local_images": []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std::io::Result<()> {
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
json!({
|
||||
"timestamp": timestamp,
|
||||
"type":"event_msg",
|
||||
"payload": {
|
||||
"type":"thread_rolled_back",
|
||||
"num_turns": num_turns
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
|
||||
turns
|
||||
.iter()
|
||||
.filter_map(|turn| match turn.items.first()? {
|
||||
ThreadItem::UserMessage { content, .. } => match content.first()? {
|
||||
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||
UserInput::Image { .. }
|
||||
| UserInput::LocalImage { .. }
|
||||
| UserInput::Skill { .. }
|
||||
| UserInput::Mention { .. } => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Helper to create a config.toml pointing at the mock model server.
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
|
||||
@@ -844,6 +844,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is
|
||||
.await??;
|
||||
let ThreadReadResponse {
|
||||
thread: read_thread,
|
||||
..
|
||||
} = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
|
||||
assert_eq!(read_thread.status, ThreadStatus::Idle);
|
||||
|
||||
@@ -135,7 +135,7 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() ->
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
assert_eq!(thread.turns.len(), 1);
|
||||
let ThreadItem::CommandExecution {
|
||||
source,
|
||||
@@ -305,7 +305,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
assert_eq!(thread.turns.len(), 1);
|
||||
assert!(
|
||||
thread.turns[0].items.iter().any(|item| {
|
||||
|
||||
@@ -288,7 +288,7 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
assert_eq!(thread.status, ThreadStatus::SystemError);
|
||||
|
||||
let unsubscribe_id = mcp
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::types::CodeTaskDetailsResponse;
|
||||
use crate::types::ConfigFileResponse;
|
||||
use crate::types::PaginatedListTaskListItem;
|
||||
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
|
||||
use crate::types::RateLimitStatusPayload;
|
||||
use crate::types::TurnAttemptsSiblingTurnsResponse;
|
||||
use anyhow::Result;
|
||||
@@ -9,6 +10,7 @@ use codex_login::CodexAuth;
|
||||
use codex_login::default_client::get_codex_user_agent;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use codex_protocol::protocol::CreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitReachedType;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use reqwest::StatusCode;
|
||||
@@ -412,12 +414,17 @@ impl Client {
|
||||
payload: RateLimitStatusPayload,
|
||||
) -> Vec<RateLimitSnapshot> {
|
||||
let plan_type = Some(Self::map_plan_type(payload.plan_type));
|
||||
let rate_limit_reached_type = payload
|
||||
.rate_limit_reached_type
|
||||
.flatten()
|
||||
.and_then(|details| Self::map_rate_limit_reached_type(details.kind));
|
||||
let mut snapshots = vec![Self::make_rate_limit_snapshot(
|
||||
Some("codex".to_string()),
|
||||
/*limit_name*/ None,
|
||||
payload.rate_limit.flatten().map(|details| *details),
|
||||
payload.credits.flatten().map(|details| *details),
|
||||
plan_type,
|
||||
rate_limit_reached_type,
|
||||
)];
|
||||
if let Some(additional) = payload.additional_rate_limits.flatten() {
|
||||
snapshots.extend(additional.into_iter().map(|details| {
|
||||
@@ -427,6 +434,7 @@ impl Client {
|
||||
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
|
||||
/*credits*/ None,
|
||||
plan_type,
|
||||
/*rate_limit_reached_type*/ None,
|
||||
)
|
||||
}));
|
||||
}
|
||||
@@ -439,6 +447,7 @@ impl Client {
|
||||
rate_limit: Option<crate::types::RateLimitStatusDetails>,
|
||||
credits: Option<crate::types::CreditStatusDetails>,
|
||||
plan_type: Option<AccountPlanType>,
|
||||
rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
) -> RateLimitSnapshot {
|
||||
let (primary, secondary) = match rate_limit {
|
||||
Some(details) => (
|
||||
@@ -454,6 +463,30 @@ impl Client {
|
||||
secondary,
|
||||
credits: Self::map_credits(credits),
|
||||
plan_type,
|
||||
rate_limit_reached_type,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_rate_limit_reached_type(
|
||||
kind: BackendRateLimitReachedKind,
|
||||
) -> Option<RateLimitReachedType> {
|
||||
match kind {
|
||||
BackendRateLimitReachedKind::RateLimitReached => {
|
||||
Some(RateLimitReachedType::RateLimitReached)
|
||||
}
|
||||
BackendRateLimitReachedKind::WorkspaceOwnerCreditsDepleted => {
|
||||
Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted)
|
||||
}
|
||||
BackendRateLimitReachedKind::WorkspaceMemberCreditsDepleted => {
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
|
||||
}
|
||||
BackendRateLimitReachedKind::WorkspaceOwnerUsageLimitReached => {
|
||||
Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached)
|
||||
}
|
||||
BackendRateLimitReachedKind::WorkspaceMemberUsageLimitReached => {
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
|
||||
}
|
||||
BackendRateLimitReachedKind::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +554,8 @@ impl Client {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_backend_openapi_models::models::AdditionalRateLimitDetails;
|
||||
use codex_backend_openapi_models::models::RateLimitReachedKind;
|
||||
use codex_backend_openapi_models::models::RateLimitReachedType as BackendRateLimitReachedType;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -574,6 +609,9 @@ mod tests {
|
||||
balance: Some(Some("9.99".to_string())),
|
||||
..Default::default()
|
||||
}))),
|
||||
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType {
|
||||
kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
|
||||
})),
|
||||
};
|
||||
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
@@ -598,6 +636,10 @@ mod tests {
|
||||
})
|
||||
);
|
||||
assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro));
|
||||
assert_eq!(
|
||||
snapshots[0].rate_limit_reached_type,
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
|
||||
);
|
||||
|
||||
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
|
||||
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
|
||||
@@ -607,6 +649,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(snapshots[1].credits, None);
|
||||
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
|
||||
assert_eq!(snapshots[1].rate_limit_reached_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -620,6 +663,7 @@ mod tests {
|
||||
rate_limit: None,
|
||||
}])),
|
||||
credits: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
@@ -645,6 +689,7 @@ mod tests {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
RateLimitSnapshot {
|
||||
limit_id: Some("codex".to_string()),
|
||||
@@ -657,6 +702,7 @@ mod tests {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(AccountPlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -667,4 +713,58 @@ mod tests {
|
||||
.unwrap_or_else(|| snapshots[0].clone());
|
||||
assert_eq!(preferred.limit_id.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_payload_maps_every_rate_limit_reached_type() {
|
||||
let cases = [
|
||||
(
|
||||
RateLimitReachedKind::RateLimitReached,
|
||||
Some(RateLimitReachedType::RateLimitReached),
|
||||
),
|
||||
(
|
||||
RateLimitReachedKind::WorkspaceOwnerCreditsDepleted,
|
||||
Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted),
|
||||
),
|
||||
(
|
||||
RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
|
||||
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted),
|
||||
),
|
||||
(
|
||||
RateLimitReachedKind::WorkspaceOwnerUsageLimitReached,
|
||||
Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached),
|
||||
),
|
||||
(
|
||||
RateLimitReachedKind::WorkspaceMemberUsageLimitReached,
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
|
||||
),
|
||||
(RateLimitReachedKind::Unknown, None),
|
||||
];
|
||||
|
||||
for (kind, expected) in cases {
|
||||
let payload = RateLimitStatusPayload {
|
||||
plan_type: crate::types::PlanType::Plus,
|
||||
rate_limit: None,
|
||||
credits: None,
|
||||
additional_rate_limits: None,
|
||||
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })),
|
||||
};
|
||||
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
assert_eq!(snapshots[0].rate_limit_reached_type, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_payload_preserves_absent_rate_limit_reached_type() {
|
||||
let payload = RateLimitStatusPayload {
|
||||
plan_type: crate::types::PlanType::Plus,
|
||||
rate_limit: None,
|
||||
credits: None,
|
||||
additional_rate_limits: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
assert_eq!(snapshots[0].rate_limit_reached_type, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ pub use codex_backend_openapi_models::models::ConfigFileResponse;
|
||||
pub use codex_backend_openapi_models::models::CreditStatusDetails;
|
||||
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
|
||||
pub use codex_backend_openapi_models::models::PlanType;
|
||||
pub use codex_backend_openapi_models::models::RateLimitReachedKind;
|
||||
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
|
||||
pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
|
||||
pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
|
||||
|
||||
@@ -13,6 +13,7 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
deno_core_icudata = { workspace = true }
|
||||
|
||||
@@ -44,8 +44,8 @@ struct SessionHandle {
|
||||
struct Inner {
|
||||
stored_values: Mutex<HashMap<String, JsonValue>>,
|
||||
sessions: Mutex<HashMap<String, SessionHandle>>,
|
||||
turn_message_tx: mpsc::UnboundedSender<TurnMessage>,
|
||||
turn_message_rx: Arc<Mutex<mpsc::UnboundedReceiver<TurnMessage>>>,
|
||||
turn_message_tx: async_channel::Sender<TurnMessage>,
|
||||
turn_message_rx: async_channel::Receiver<TurnMessage>,
|
||||
next_cell_id: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -55,14 +55,14 @@ pub struct CodeModeService {
|
||||
|
||||
impl CodeModeService {
|
||||
pub fn new() -> Self {
|
||||
let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel();
|
||||
let (turn_message_tx, turn_message_rx) = async_channel::unbounded();
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
stored_values: Mutex::new(HashMap::new()),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
turn_message_tx,
|
||||
turn_message_rx: Arc::new(Mutex::new(turn_message_rx)),
|
||||
turn_message_rx,
|
||||
next_cell_id: AtomicU64::new(1),
|
||||
}),
|
||||
}
|
||||
@@ -146,16 +146,13 @@ impl CodeModeService {
|
||||
pub fn start_turn_worker(&self, host: Arc<dyn CodeModeTurnHost>) -> CodeModeTurnWorker {
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let turn_message_rx = Arc::clone(&self.inner.turn_message_rx);
|
||||
let turn_message_rx = self.inner.turn_message_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let next_message = tokio::select! {
|
||||
_ = &mut shutdown_rx => break,
|
||||
message = async {
|
||||
let mut turn_message_rx = turn_message_rx.lock().await;
|
||||
turn_message_rx.recv().await
|
||||
} => message,
|
||||
message = turn_message_rx.recv() => message.ok(),
|
||||
};
|
||||
let Some(next_message) = next_message else {
|
||||
break;
|
||||
@@ -361,7 +358,7 @@ async fn run_session_control(
|
||||
cell_id: cell_id.clone(),
|
||||
call_id,
|
||||
text,
|
||||
});
|
||||
}).await;
|
||||
}
|
||||
RuntimeEvent::ToolCall { id, name, input } => {
|
||||
let _ = inner.turn_message_tx.send(TurnMessage::ToolCall {
|
||||
@@ -369,7 +366,7 @@ async fn run_session_control(
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
});
|
||||
}).await;
|
||||
}
|
||||
RuntimeEvent::Result {
|
||||
stored_values,
|
||||
@@ -500,12 +497,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn test_inner() -> Arc<Inner> {
|
||||
let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel();
|
||||
let (turn_message_tx, turn_message_rx) = async_channel::unbounded();
|
||||
Arc::new(Inner {
|
||||
stored_values: Mutex::new(HashMap::new()),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
turn_message_tx,
|
||||
turn_message_rx: Arc::new(Mutex::new(turn_message_rx)),
|
||||
turn_message_rx,
|
||||
next_cell_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
|
||||
@@ -65,9 +65,9 @@ enum WsCommand {
|
||||
impl WsStream {
|
||||
fn new(
|
||||
inner: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Result<Message, WsError>>) {
|
||||
) -> (Self, async_channel::Receiver<Result<Message, WsError>>) {
|
||||
let (tx_command, mut rx_command) = mpsc::channel::<WsCommand>(32);
|
||||
let (tx_message, rx_message) = mpsc::unbounded_channel::<Result<Message, WsError>>();
|
||||
let (tx_message, rx_message) = async_channel::unbounded::<Result<Message, WsError>>();
|
||||
|
||||
let pump_task = tokio::spawn(async move {
|
||||
let mut inner = inner;
|
||||
@@ -110,7 +110,7 @@ impl WsStream {
|
||||
trace!(payload_len = payload.len(), "realtime websocket received ping");
|
||||
if let Err(err) = inner.send(Message::Pong(payload)).await {
|
||||
error!("realtime websocket failed to send pong: {err}");
|
||||
let _ = tx_message.send(Err(err));
|
||||
let _ = tx_message.send(Err(err)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ impl WsStream {
|
||||
}
|
||||
Message::Ping(_) | Message::Pong(_) => {}
|
||||
}
|
||||
if tx_message.send(Ok(message)).is_err() {
|
||||
if tx_message.send(Ok(message)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
@@ -147,7 +147,7 @@ impl WsStream {
|
||||
}
|
||||
Err(err) => {
|
||||
error!("realtime websocket receive failed: {err}");
|
||||
let _ = tx_message.send(Err(err));
|
||||
let _ = tx_message.send(Err(err)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ pub struct RealtimeWebsocketWriter {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RealtimeWebsocketEvents {
|
||||
rx_message: Arc<Mutex<mpsc::UnboundedReceiver<Result<Message, WsError>>>>,
|
||||
rx_message: async_channel::Receiver<Result<Message, WsError>>,
|
||||
active_transcript: Arc<Mutex<ActiveTranscriptState>>,
|
||||
event_parser: RealtimeEventParser,
|
||||
is_closed: Arc<AtomicBool>,
|
||||
@@ -256,7 +256,7 @@ impl RealtimeWebsocketConnection {
|
||||
|
||||
fn new(
|
||||
stream: WsStream,
|
||||
rx_message: mpsc::UnboundedReceiver<Result<Message, WsError>>,
|
||||
rx_message: async_channel::Receiver<Result<Message, WsError>>,
|
||||
event_parser: RealtimeEventParser,
|
||||
) -> Self {
|
||||
let stream = Arc::new(stream);
|
||||
@@ -268,7 +268,7 @@ impl RealtimeWebsocketConnection {
|
||||
event_parser,
|
||||
},
|
||||
events: RealtimeWebsocketEvents {
|
||||
rx_message: Arc::new(Mutex::new(rx_message)),
|
||||
rx_message,
|
||||
active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())),
|
||||
event_parser,
|
||||
is_closed,
|
||||
@@ -369,16 +369,16 @@ impl RealtimeWebsocketEvents {
|
||||
}
|
||||
|
||||
loop {
|
||||
let msg = match self.rx_message.lock().await.recv().await {
|
||||
Some(Ok(msg)) => msg,
|
||||
Some(Err(err)) => {
|
||||
let msg = match self.rx_message.recv().await {
|
||||
Ok(Ok(msg)) => msg,
|
||||
Ok(Err(err)) => {
|
||||
self.is_closed.store(true, Ordering::SeqCst);
|
||||
error!("realtime websocket read failed: {err}");
|
||||
return Err(ApiError::Stream(format!(
|
||||
"failed to read websocket message: {err}"
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
Err(_) => {
|
||||
self.is_closed.store(true, Ordering::SeqCst);
|
||||
info!("realtime websocket event stream ended");
|
||||
return Ok(None);
|
||||
|
||||
@@ -93,6 +93,7 @@ pub fn parse_rate_limit_for_limit(
|
||||
secondary,
|
||||
credits,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +157,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
|
||||
secondary,
|
||||
credits,
|
||||
plan_type: event.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ pub use self::additional_rate_limit_details::AdditionalRateLimitDetails;
|
||||
|
||||
pub(crate) mod rate_limit_status_payload;
|
||||
pub use self::rate_limit_status_payload::PlanType;
|
||||
pub use self::rate_limit_status_payload::RateLimitReachedKind;
|
||||
pub use self::rate_limit_status_payload::RateLimitReachedType;
|
||||
pub use self::rate_limit_status_payload::RateLimitStatusPayload;
|
||||
|
||||
pub(crate) mod rate_limit_status_details;
|
||||
|
||||
@@ -37,6 +37,13 @@ pub struct RateLimitStatusPayload {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub additional_rate_limits: Option<Option<Vec<models::AdditionalRateLimitDetails>>>,
|
||||
#[serde(
|
||||
rename = "rate_limit_reached_type",
|
||||
default,
|
||||
with = "::serde_with::rust::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub rate_limit_reached_type: Option<Option<RateLimitReachedType>>,
|
||||
}
|
||||
|
||||
impl RateLimitStatusPayload {
|
||||
@@ -46,10 +53,36 @@ impl RateLimitStatusPayload {
|
||||
rate_limit: None,
|
||||
credits: None,
|
||||
additional_rate_limits: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RateLimitReachedType {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: RateLimitReachedKind,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default,
|
||||
)]
|
||||
pub enum RateLimitReachedKind {
|
||||
#[serde(rename = "rate_limit_reached")]
|
||||
RateLimitReached,
|
||||
#[serde(rename = "workspace_owner_credits_depleted")]
|
||||
WorkspaceOwnerCreditsDepleted,
|
||||
#[serde(rename = "workspace_member_credits_depleted")]
|
||||
WorkspaceMemberCreditsDepleted,
|
||||
#[serde(rename = "workspace_owner_usage_limit_reached")]
|
||||
WorkspaceOwnerUsageLimitReached,
|
||||
#[serde(rename = "workspace_member_usage_limit_reached")]
|
||||
WorkspaceMemberUsageLimitReached,
|
||||
#[serde(rename = "unknown", other)]
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, Default,
|
||||
)]
|
||||
|
||||
@@ -50,8 +50,10 @@ use codex_protocol::protocol::McpStartupStatus;
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::LocalStdioServerLauncher;
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
use codex_rmcp_client::SendElicitation;
|
||||
use codex_rmcp_client::StdioServerLauncher;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::future::FutureExt;
|
||||
use futures::future::Shared;
|
||||
@@ -1500,7 +1502,8 @@ async fn make_rmcp_client(
|
||||
.map(|(key, value)| (key.into(), value.into()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
});
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd)
|
||||
let launcher = Arc::new(LocalStdioServerLauncher) as Arc<dyn StdioServerLauncher>;
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
|
||||
.await
|
||||
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
|
||||
}
|
||||
|
||||
@@ -158,11 +158,9 @@ where
|
||||
let path = match next_token.as_deref() {
|
||||
Some(token) => {
|
||||
let encoded_token = urlencoding::encode(token);
|
||||
format!(
|
||||
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
|
||||
)
|
||||
format!("/connectors/directory/list?token={encoded_token}&external_logos=true")
|
||||
}
|
||||
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
|
||||
None => "/connectors/directory/list?external_logos=true".to_string(),
|
||||
};
|
||||
let response = fetch_page(path).await?;
|
||||
apps.extend(
|
||||
@@ -416,6 +414,7 @@ mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -551,4 +550,52 @@ mod tests {
|
||||
assert_eq!(connectors[1].name, "Beta");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_connectors_omits_tier_for_all_pages() -> anyhow::Result<()> {
|
||||
let requested_paths: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let paths = Arc::clone(&requested_paths);
|
||||
|
||||
let apps = list_directory_connectors(&mut move |path| {
|
||||
let paths = Arc::clone(&paths);
|
||||
async move {
|
||||
paths
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(path.clone());
|
||||
if path == "/connectors/directory/list?external_logos=true" {
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![app("alpha", "Alpha")],
|
||||
next_token: Some("page 2".to_string()),
|
||||
})
|
||||
} else {
|
||||
assert_eq!(
|
||||
path,
|
||||
"/connectors/directory/list?token=page%202&external_logos=true"
|
||||
);
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![app("beta", "Beta")],
|
||||
next_token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
apps.iter().map(|app| app.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["alpha", "beta"]
|
||||
);
|
||||
assert_eq!(
|
||||
requested_paths
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_slice(),
|
||||
&[
|
||||
"/connectors/directory/list?external_logos=true".to_string(),
|
||||
"/connectors/directory/list?token=page%202&external_logos=true".to_string(),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tracing::warn;
|
||||
|
||||
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
@@ -150,6 +152,14 @@ pub fn refresh_curated_plugin_cache(
|
||||
}
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path,
|
||||
MarketplacePluginSource::Git { .. } => {
|
||||
warn!(
|
||||
plugin = plugin_name,
|
||||
marketplace = OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
"skipping remote curated plugin source during cache refresh"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
plugin_sources.insert(plugin_name, source_path);
|
||||
}
|
||||
@@ -227,7 +237,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let marketplace_outcome = list_marketplaces(additional_roots)
|
||||
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
|
||||
let mut plugin_sources = HashMap::<String, (AbsolutePathBuf, String)>::new();
|
||||
let mut plugin_sources = HashMap::<String, MarketplacePluginSource>::new();
|
||||
|
||||
for marketplace in marketplace_outcome.marketplaces {
|
||||
if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
@@ -256,19 +266,14 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
||||
continue;
|
||||
}
|
||||
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path,
|
||||
};
|
||||
let plugin_version = plugin_version_for_source(source_path.as_path())
|
||||
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
|
||||
plugin_sources.insert(plugin_key, (source_path, plugin_version));
|
||||
plugin_sources.insert(plugin_key, plugin.source);
|
||||
}
|
||||
}
|
||||
|
||||
let mut cache_refreshed = false;
|
||||
for plugin_id in configured_non_curated_plugin_ids {
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let Some((source_path, plugin_version)) = plugin_sources.get(&plugin_key).cloned() else {
|
||||
let Some(source) = plugin_sources.get(&plugin_key).cloned() else {
|
||||
warn!(
|
||||
plugin = plugin_id.plugin_name,
|
||||
marketplace = plugin_id.marketplace_name,
|
||||
@@ -276,6 +281,13 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let materialized =
|
||||
materialize_marketplace_plugin_source(codex_home, &source).map_err(|err| {
|
||||
format!("failed to materialize plugin source for {plugin_key}: {err}")
|
||||
})?;
|
||||
let source_path = materialized.path.clone();
|
||||
let plugin_version = plugin_version_for_source(source_path.as_path())
|
||||
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
|
||||
|
||||
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
|
||||
&& store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str())
|
||||
@@ -836,3 +848,186 @@ fn normalize_plugin_mcp_server_value(
|
||||
struct PluginMcpDiscovery {
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MaterializedMarketplacePluginSource {
|
||||
pub path: AbsolutePathBuf,
|
||||
_tempdir: Option<TempDir>,
|
||||
}
|
||||
|
||||
pub fn materialize_marketplace_plugin_source(
|
||||
codex_home: &Path,
|
||||
source: &MarketplacePluginSource,
|
||||
) -> Result<MaterializedMarketplacePluginSource, String> {
|
||||
match source {
|
||||
MarketplacePluginSource::Local { path } => Ok(MaterializedMarketplacePluginSource {
|
||||
path: path.clone(),
|
||||
_tempdir: None,
|
||||
}),
|
||||
MarketplacePluginSource::Git {
|
||||
url,
|
||||
path,
|
||||
ref_name,
|
||||
sha,
|
||||
} => {
|
||||
let staging_root = codex_home.join("plugins/.marketplace-plugin-source-staging");
|
||||
fs::create_dir_all(&staging_root).map_err(|err| {
|
||||
format!(
|
||||
"failed to create marketplace plugin source staging directory {}: {err}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
let tempdir = tempfile::Builder::new()
|
||||
.prefix("marketplace-plugin-source-")
|
||||
.tempdir_in(&staging_root)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create marketplace plugin source staging directory in {}: {err}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
clone_git_plugin_source(
|
||||
url,
|
||||
ref_name.as_deref(),
|
||||
sha.as_deref(),
|
||||
path.as_deref(),
|
||||
tempdir.path(),
|
||||
)?;
|
||||
let path = if let Some(path) = path {
|
||||
AbsolutePathBuf::try_from(tempdir.path().join(path)).map_err(|err| {
|
||||
format!("failed to resolve materialized plugin source path: {err}")
|
||||
})?
|
||||
} else {
|
||||
AbsolutePathBuf::try_from(tempdir.path().to_path_buf()).map_err(|err| {
|
||||
format!("failed to resolve materialized plugin source path: {err}")
|
||||
})?
|
||||
};
|
||||
Ok(MaterializedMarketplacePluginSource {
|
||||
path,
|
||||
_tempdir: Some(tempdir),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_git_plugin_source(
|
||||
url: &str,
|
||||
ref_name: Option<&str>,
|
||||
sha: Option<&str>,
|
||||
sparse_checkout_path: Option<&str>,
|
||||
destination: &Path,
|
||||
) -> Result<(), String> {
|
||||
if let Some(sparse_checkout_path) = sparse_checkout_path {
|
||||
run_git(
|
||||
&[
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--sparse",
|
||||
"--no-checkout",
|
||||
url,
|
||||
destination.to_string_lossy().as_ref(),
|
||||
],
|
||||
/*cwd*/ None,
|
||||
)?;
|
||||
run_git(
|
||||
&[
|
||||
"sparse-checkout",
|
||||
"set",
|
||||
"--no-cone",
|
||||
"--",
|
||||
sparse_checkout_path,
|
||||
],
|
||||
Some(destination),
|
||||
)?;
|
||||
} else {
|
||||
run_git(
|
||||
&["clone", url, destination.to_string_lossy().as_ref()],
|
||||
/*cwd*/ None,
|
||||
)?;
|
||||
}
|
||||
if let Some(target) = sha.or(ref_name) {
|
||||
run_git(&["checkout", target], Some(destination))?;
|
||||
} else if sparse_checkout_path.is_some() {
|
||||
run_git(&["checkout"], Some(destination))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), String> {
|
||||
let mut command = Command::new("git");
|
||||
command.args(args);
|
||||
command.env("GIT_TERMINAL_PROMPT", "0");
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|err| format!("failed to run git {}: {err}", args.join(" ")))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
|
||||
args.join(" "),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn materialize_git_subdir_uses_sparse_checkout() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let repo = tempfile::tempdir().expect("create git repo");
|
||||
let plugin_dir = repo.path().join("plugins/toolkit");
|
||||
fs::create_dir_all(&plugin_dir).expect("create plugin directory");
|
||||
fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin");
|
||||
fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker");
|
||||
fs::write(repo.path().join("plugins/other/marker.txt"), "other")
|
||||
.expect("write other marker");
|
||||
fs::write(repo.path().join("root.txt"), "root").expect("write root marker");
|
||||
|
||||
run_git(&["init"], Some(repo.path())).expect("init git repo");
|
||||
run_git(
|
||||
&["config", "user.email", "test@example.com"],
|
||||
Some(repo.path()),
|
||||
)
|
||||
.expect("configure git email");
|
||||
run_git(&["config", "user.name", "Test User"], Some(repo.path()))
|
||||
.expect("configure git name");
|
||||
run_git(&["add", "."], Some(repo.path())).expect("stage git repo");
|
||||
run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo");
|
||||
|
||||
let materialized = materialize_marketplace_plugin_source(
|
||||
codex_home.path(),
|
||||
&MarketplacePluginSource::Git {
|
||||
url: repo.path().display().to_string(),
|
||||
path: Some("plugins/toolkit".to_string()),
|
||||
ref_name: None,
|
||||
sha: None,
|
||||
},
|
||||
)
|
||||
.expect("materialize git source");
|
||||
|
||||
assert_eq!(
|
||||
plugin_dir.file_name(),
|
||||
materialized.path.as_path().file_name()
|
||||
);
|
||||
assert!(materialized.path.as_path().join("marker.txt").is_file());
|
||||
let checkout_root = materialized
|
||||
.path
|
||||
.as_path()
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.expect("materialized path should be nested under checkout root");
|
||||
assert!(!checkout_root.join("root.txt").exists());
|
||||
assert!(!checkout_root.join("plugins/other/marker.txt").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
@@ -67,7 +66,15 @@ pub struct MarketplacePlugin {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MarketplacePluginSource {
|
||||
Local { path: AbsolutePathBuf },
|
||||
Local {
|
||||
path: AbsolutePathBuf,
|
||||
},
|
||||
Git {
|
||||
url: String,
|
||||
path: Option<String>,
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -386,27 +393,26 @@ fn resolve_marketplace_plugin_entry(
|
||||
policy,
|
||||
category,
|
||||
} = plugin;
|
||||
let Some(source_path) = resolve_supported_plugin_source_path(marketplace_path, &name, source)
|
||||
else {
|
||||
let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest = load_plugin_manifest(source_path.as_path());
|
||||
let mut interface = manifest
|
||||
.as_ref()
|
||||
.and_then(|manifest| manifest.interface.clone());
|
||||
if let Some(category) = category {
|
||||
// Marketplace taxonomy wins when both sources provide a category.
|
||||
interface
|
||||
.get_or_insert_with(PluginManifestInterface::default)
|
||||
.category = Some(category);
|
||||
}
|
||||
let manifest = match &source {
|
||||
MarketplacePluginSource::Local { path } => load_plugin_manifest(path.as_path()),
|
||||
MarketplacePluginSource::Git { .. } => None,
|
||||
};
|
||||
let interface = plugin_interface_with_marketplace_category(
|
||||
manifest
|
||||
.as_ref()
|
||||
.and_then(|manifest| manifest.interface.clone()),
|
||||
category,
|
||||
);
|
||||
|
||||
Ok(Some(ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
})?,
|
||||
source: MarketplacePluginSource::Local { path: source_path },
|
||||
source,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: policy.installation,
|
||||
authentication: policy.authentication,
|
||||
@@ -417,27 +423,13 @@ fn resolve_marketplace_plugin_entry(
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_supported_plugin_source_path(
|
||||
fn resolve_supported_plugin_source(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
) -> Option<MarketplacePluginSource> {
|
||||
match source {
|
||||
RawMarketplaceManifestPluginSource::Local { path } => {
|
||||
match resolve_local_plugin_source_path(marketplace_path, &path) {
|
||||
Ok(path) => Some(path),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
plugin = plugin_name,
|
||||
error = %err,
|
||||
"skipping marketplace plugin that failed to resolve"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
RawMarketplaceManifestPluginSource::Unsupported => {
|
||||
RawMarketplaceManifestPluginSource::Unsupported(_) => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
plugin = plugin_name,
|
||||
@@ -445,27 +437,85 @@ fn resolve_supported_plugin_source_path(
|
||||
);
|
||||
None
|
||||
}
|
||||
source => match resolve_plugin_source(marketplace_path, source) {
|
||||
Ok(source) => Some(source),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
plugin = plugin_name,
|
||||
error = %err,
|
||||
"skipping marketplace plugin that failed to resolve"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_plugin_source(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
) -> Result<MarketplacePluginSource, MarketplaceError> {
|
||||
match source {
|
||||
RawMarketplaceManifestPluginSource::Path(path)
|
||||
| RawMarketplaceManifestPluginSource::Object(
|
||||
RawMarketplaceManifestPluginSourceObject::Local { path },
|
||||
) => Ok(MarketplacePluginSource::Local {
|
||||
path: resolve_local_plugin_source_path(marketplace_path, &path)?,
|
||||
}),
|
||||
RawMarketplaceManifestPluginSource::Object(
|
||||
RawMarketplaceManifestPluginSourceObject::Url {
|
||||
url,
|
||||
path,
|
||||
ref_name,
|
||||
sha,
|
||||
},
|
||||
) => Ok(MarketplacePluginSource::Git {
|
||||
url: normalize_git_plugin_source_url(marketplace_path, &url)?,
|
||||
path: path
|
||||
.as_deref()
|
||||
.map(|path| normalize_remote_plugin_subdir(marketplace_path, path))
|
||||
.transpose()?,
|
||||
ref_name: normalize_optional_git_selector(&ref_name),
|
||||
sha: normalize_optional_git_selector(&sha),
|
||||
}),
|
||||
RawMarketplaceManifestPluginSource::Object(
|
||||
RawMarketplaceManifestPluginSourceObject::GitSubdir {
|
||||
url,
|
||||
path,
|
||||
ref_name,
|
||||
sha,
|
||||
},
|
||||
) => Ok(MarketplacePluginSource::Git {
|
||||
url: normalize_git_plugin_source_url(marketplace_path, &url)?,
|
||||
path: Some(normalize_remote_plugin_subdir(marketplace_path, &path)?),
|
||||
ref_name: normalize_optional_git_selector(&ref_name),
|
||||
sha: normalize_optional_git_selector(&sha),
|
||||
}),
|
||||
RawMarketplaceManifestPluginSource::Unsupported(_) => {
|
||||
unreachable!("unsupported plugin sources should be filtered before resolution")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_local_plugin_source_path(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
source_path: &str,
|
||||
path: &str,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
let Some(source_path) = source_path.strip_prefix("./") else {
|
||||
let Some(path) = path.strip_prefix("./") else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must start with `./`".to_string(),
|
||||
});
|
||||
};
|
||||
if source_path.is_empty() {
|
||||
if path.is_empty() {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let relative_source_path = Path::new(source_path);
|
||||
let relative_source_path = Path::new(path);
|
||||
if relative_source_path
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
@@ -481,6 +531,151 @@ fn resolve_local_plugin_source_path(
|
||||
Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path))
|
||||
}
|
||||
|
||||
fn normalize_remote_plugin_subdir(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
path: &str,
|
||||
) -> Result<String, MarketplaceError> {
|
||||
let path = path.trim();
|
||||
let path = path.strip_prefix("./").unwrap_or(path);
|
||||
if path.is_empty() {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "git plugin source path must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
let relative_path = Path::new(path);
|
||||
if relative_path
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "git plugin source path must stay within the repository root".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
fn normalize_git_plugin_source_url(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
url: &str,
|
||||
) -> Result<String, MarketplaceError> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "git plugin source url must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return Ok(normalize_github_git_url(url));
|
||||
}
|
||||
if url.starts_with("./")
|
||||
|| url.starts_with("../")
|
||||
|| url.starts_with(".\\")
|
||||
|| url.starts_with("..\\")
|
||||
{
|
||||
return normalize_relative_git_plugin_source_url(marketplace_path, url);
|
||||
}
|
||||
if url.starts_with("file://") || url.starts_with('/') {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
if url.starts_with("ssh://") || url.starts_with("git@") && url.contains(':') {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
if let Some(url) = normalize_github_shorthand_url(url) {
|
||||
return Ok(url);
|
||||
}
|
||||
|
||||
Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("invalid git plugin source url: {url}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_relative_git_plugin_source_url(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
url: &str,
|
||||
) -> Result<String, MarketplaceError> {
|
||||
let mut normalized = marketplace_root_dir(marketplace_path)?
|
||||
.as_path()
|
||||
.to_path_buf();
|
||||
for segment in url.split(['/', '\\']) {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "relative git plugin source url must stay within the marketplace root"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
segment => normalized.push(segment),
|
||||
}
|
||||
}
|
||||
Ok(normalized.display().to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_git_selector(value: &Option<String>) -> Option<String> {
|
||||
value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn normalize_github_git_url(url: &str) -> String {
|
||||
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
|
||||
format!("{url}.git")
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_github_shorthand_url(source: &str) -> Option<String> {
|
||||
if !looks_like_github_shorthand(source) {
|
||||
return None;
|
||||
}
|
||||
let mut segments = source.split('/');
|
||||
let owner = segments.next()?;
|
||||
let repo = segments.next()?;
|
||||
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
if repo.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("https://github.com/{owner}/{repo}.git"))
|
||||
}
|
||||
|
||||
fn looks_like_github_shorthand(source: &str) -> bool {
|
||||
let mut segments = source.split('/');
|
||||
let owner = segments.next();
|
||||
let repo = segments.next();
|
||||
let extra = segments.next();
|
||||
owner.is_some_and(is_github_shorthand_segment)
|
||||
&& repo.is_some_and(is_github_shorthand_segment)
|
||||
&& extra.is_none()
|
||||
}
|
||||
|
||||
fn is_github_shorthand_segment(segment: &str) -> bool {
|
||||
!segment.is_empty()
|
||||
&& segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
pub fn plugin_interface_with_marketplace_category(
|
||||
mut interface: Option<PluginManifestInterface>,
|
||||
category: Option<String>,
|
||||
) -> Option<PluginManifestInterface> {
|
||||
if let Some(category) = category {
|
||||
// Marketplace taxonomy wins when both sources provide a category.
|
||||
interface
|
||||
.get_or_insert_with(PluginManifestInterface::default)
|
||||
.category = Some(category);
|
||||
}
|
||||
interface
|
||||
}
|
||||
|
||||
fn marketplace_root_dir(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
@@ -533,33 +728,36 @@ struct RawMarketplaceManifestPluginPolicy {
|
||||
products: Option<Vec<Product>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawMarketplaceManifestPluginSource {
|
||||
Local { path: String },
|
||||
// Mixed-source marketplaces should still contribute the local plugins we can load.
|
||||
Unsupported,
|
||||
Path(String),
|
||||
Object(RawMarketplaceManifestPluginSourceObject),
|
||||
#[allow(dead_code)]
|
||||
Unsupported(JsonValue),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RawMarketplaceManifestPluginSource {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let source = JsonValue::deserialize(deserializer)?;
|
||||
Ok(match source {
|
||||
JsonValue::String(path) => Self::Local { path },
|
||||
JsonValue::Object(object) => match object.get("source").and_then(JsonValue::as_str) {
|
||||
Some("local") => match object.get("path").and_then(JsonValue::as_str) {
|
||||
Some(path) => Self::Local {
|
||||
path: path.to_string(),
|
||||
},
|
||||
None => Self::Unsupported,
|
||||
},
|
||||
_ => Self::Unsupported,
|
||||
},
|
||||
_ => Self::Unsupported,
|
||||
})
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "source", rename_all = "lowercase")]
|
||||
enum RawMarketplaceManifestPluginSourceObject {
|
||||
Local {
|
||||
path: String,
|
||||
},
|
||||
Url {
|
||||
url: String,
|
||||
path: Option<String>,
|
||||
#[serde(rename = "ref")]
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
#[serde(rename = "git-subdir")]
|
||||
GitSubdir {
|
||||
url: String,
|
||||
path: String,
|
||||
#[serde(rename = "ref")]
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn resolve_marketplace_interface(
|
||||
|
||||
@@ -112,6 +112,214 @@ fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_supports_git_subdir_sources() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "remote-plugin",
|
||||
"source": {
|
||||
"source": "git-subdir",
|
||||
"url": "openai/joey_marketplace3",
|
||||
"path": "plugins/toolkit",
|
||||
"ref": "main",
|
||||
"sha": "abc123"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"remote-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("remote-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source: MarketplacePluginSource::Git {
|
||||
url: "https://github.com/openai/joey_marketplace3.git".to_string(),
|
||||
path: Some("plugins/toolkit".to_string()),
|
||||
ref_name: Some("main".to_string()),
|
||||
sha: Some("abc123".to_string()),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
manifest: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_normalizes_github_shorthand_with_dot_git_suffix() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "remote-plugin",
|
||||
"source": {
|
||||
"source": "git-subdir",
|
||||
"url": "openai/toolkit.git",
|
||||
"path": "plugins/toolkit"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"remote-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source,
|
||||
MarketplacePluginSource::Git {
|
||||
url: "https://github.com/openai/toolkit.git".to_string(),
|
||||
path: Some("plugins/toolkit".to_string()),
|
||||
ref_name: None,
|
||||
sha: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_normalizes_relative_git_source_urls_to_marketplace_root() {
|
||||
for source_url in ["./remotes/toolkit.git", ".\\remotes\\toolkit.git"] {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let remote_repo = repo_root.join("remotes").join("toolkit.git");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(&remote_repo).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "remote-plugin",
|
||||
"source": {{
|
||||
"source": "git-subdir",
|
||||
"url": "{}",
|
||||
"path": "plugins/toolkit"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}"#,
|
||||
source_url.replace('\\', "\\\\")
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"remote-plugin",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source,
|
||||
MarketplacePluginSource::Git {
|
||||
url: remote_repo.display().to_string(),
|
||||
path: Some("plugins/toolkit".to_string()),
|
||||
ref_name: None,
|
||||
sha: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_relative_git_plugin_source_url_rejects_parent_traversal() {
|
||||
for source_url in [
|
||||
"../toolkit.git",
|
||||
"./../toolkit.git",
|
||||
"..\\toolkit.git",
|
||||
".\\..\\toolkit.git",
|
||||
] {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let marketplace_path = repo_root.join(".agents/plugins/marketplace.json");
|
||||
let marketplace_path = AbsolutePathBuf::try_from(marketplace_path).unwrap();
|
||||
let err =
|
||||
normalize_relative_git_plugin_source_url(&marketplace_path, source_url).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"invalid marketplace file `{}`: relative git plugin source url must stay within the marketplace root",
|
||||
marketplace_path.display()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_skips_root_equivalent_git_subdir_paths() {
|
||||
for path in [".", "./", "plugins/.."] {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "remote-plugin",
|
||||
"source": {{
|
||||
"source": "git-subdir",
|
||||
"url": "openai/toolkit",
|
||||
"path": "{path}"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"remote-plugin",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin `remote-plugin` was not found in marketplace `codex-curated`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_reports_missing_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -827,7 +1035,7 @@ fn list_marketplaces_reports_marketplace_load_errors() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_skips_unsupported_plugin_sources_but_keeps_local_plugins() {
|
||||
fn list_marketplaces_keeps_remote_and_local_plugin_sources() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
@@ -845,7 +1053,7 @@ fn list_marketplaces_skips_unsupported_plugin_sources_but_keeps_local_plugins()
|
||||
"name": "url-plugin",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/example/plugin.git"
|
||||
"url": "https://github.com/example/plugin"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -854,7 +1062,8 @@ fn list_marketplaces_skips_unsupported_plugin_sources_but_keeps_local_plugins()
|
||||
"source": "git-subdir",
|
||||
"url": "owner/repo",
|
||||
"path": "plugins/example",
|
||||
"ref": "main"
|
||||
"ref": "main",
|
||||
"sha": "abc123"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -869,14 +1078,53 @@ fn list_marketplaces_skips_unsupported_plugin_sources_but_keeps_local_plugins()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert_eq!(marketplaces[0].name, "mixed-source-marketplace");
|
||||
assert_eq!(marketplaces[0].plugins.len(), 1);
|
||||
assert_eq!(marketplaces[0].plugins[0].name, "local-plugin");
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].source,
|
||||
MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugins/local-plugin")).unwrap(),
|
||||
}
|
||||
marketplaces[0].plugins,
|
||||
vec![
|
||||
MarketplacePlugin {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugins/local-plugin"))
|
||||
.unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePlugin {
|
||||
name: "url-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Git {
|
||||
url: "https://github.com/example/plugin.git".to_string(),
|
||||
path: None,
|
||||
ref_name: None,
|
||||
sha: None,
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePlugin {
|
||||
name: "git-subdir-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
path: Some("plugins/example".to_string()),
|
||||
ref_name: Some("main".to_string()),
|
||||
sha: Some("abc123".to_string()),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1126,35 +1374,6 @@ fn find_marketplace_plugin_skips_invalid_local_paths() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_skips_unsupported_sources() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
let marketplace_path = write_alternate_marketplace(
|
||||
&repo_root,
|
||||
r#"{
|
||||
"name": "alternate-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "remote-plugin",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/example/plugin.git"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
let err = find_marketplace_plugin(&marketplace_path, "remote-plugin").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin `remote-plugin` was not found in marketplace `alternate-marketplace`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marketplace_plugin_uses_first_duplicate_entry() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -157,6 +157,7 @@ pub use rollout::RolloutRecorder;
|
||||
pub use rollout::RolloutRecorderParams;
|
||||
pub use rollout::SESSIONS_SUBDIR;
|
||||
pub use rollout::SessionMeta;
|
||||
pub use rollout::SortDirection;
|
||||
pub use rollout::ThreadItem;
|
||||
pub use rollout::ThreadSortKey;
|
||||
pub use rollout::ThreadsPage;
|
||||
|
||||
@@ -79,6 +79,7 @@ async fn has_threads(store: &LocalThreadStore, archived: bool) -> io::Result<boo
|
||||
page_size: 1,
|
||||
cursor: None,
|
||||
sort_key: ThreadSortKey::CreatedAt,
|
||||
sort_direction: codex_thread_store::SortDirection::Desc,
|
||||
allowed_sources: Vec::new(),
|
||||
model_providers: None,
|
||||
archived,
|
||||
|
||||
@@ -22,6 +22,7 @@ use codex_core_plugins::loader::load_plugin_mcp_servers;
|
||||
use codex_core_plugins::loader::load_plugin_skills;
|
||||
use codex_core_plugins::loader::load_plugins_from_layer_stack;
|
||||
use codex_core_plugins::loader::log_plugin_load_errors;
|
||||
use codex_core_plugins::loader::materialize_marketplace_plugin_source;
|
||||
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
|
||||
use codex_core_plugins::loader::refresh_curated_plugin_cache;
|
||||
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
|
||||
@@ -39,6 +40,7 @@ use codex_core_plugins::marketplace::find_installable_marketplace_plugin;
|
||||
use codex_core_plugins::marketplace::find_marketplace_plugin;
|
||||
use codex_core_plugins::marketplace::list_marketplaces;
|
||||
use codex_core_plugins::marketplace::load_marketplace;
|
||||
use codex_core_plugins::marketplace::plugin_interface_with_marketplace_category;
|
||||
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
||||
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||
use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names;
|
||||
@@ -188,6 +190,12 @@ pub struct PluginDetail {
|
||||
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub details_unavailable_reason: Option<PluginDetailsUnavailableReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginDetailsUnavailableReason {
|
||||
InstallRequiredForRemoteSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -588,8 +596,12 @@ impl PluginsManager {
|
||||
None
|
||||
};
|
||||
let store = self.store.clone();
|
||||
let codex_home = self.codex_home.clone();
|
||||
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
|
||||
let MarketplacePluginSource::Local { path: source_path } = resolved.source;
|
||||
let materialized =
|
||||
materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source)
|
||||
.map_err(PluginStoreError::Invalid)?;
|
||||
let source_path = materialized.path;
|
||||
if let Some(plugin_version) = plugin_version {
|
||||
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
|
||||
} else {
|
||||
@@ -754,6 +766,14 @@ impl PluginsManager {
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path,
|
||||
MarketplacePluginSource::Git { .. } => {
|
||||
warn!(
|
||||
plugin = plugin_name,
|
||||
marketplace = %marketplace_name,
|
||||
"skipping remote plugin source during remote sync"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let current_enabled = configured_plugins
|
||||
.get(&plugin_key)
|
||||
@@ -1032,9 +1052,55 @@ impl PluginsManager {
|
||||
});
|
||||
}
|
||||
|
||||
let source_path = match &plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path.clone(),
|
||||
};
|
||||
let plugin_id =
|
||||
PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| {
|
||||
match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
}
|
||||
})?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && !plugin.installed {
|
||||
return Ok(PluginDetail {
|
||||
id: plugin_key,
|
||||
name: plugin.name,
|
||||
description: None,
|
||||
source: plugin.source,
|
||||
policy: plugin.policy,
|
||||
interface: plugin.interface,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
skills: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
apps: Vec::new(),
|
||||
mcp_server_names: Vec::new(),
|
||||
details_unavailable_reason: Some(
|
||||
PluginDetailsUnavailableReason::InstallRequiredForRemoteSource,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let source_path =
|
||||
if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && plugin.installed {
|
||||
self.store.active_plugin_root(&plugin_id).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(format!(
|
||||
"installed plugin cache entry is missing for {plugin_key}"
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
let codex_home = self.codex_home.clone();
|
||||
let source = plugin.source.clone();
|
||||
let materialized = tokio::task::spawn_blocking(move || {
|
||||
materialize_marketplace_plugin_source(codex_home.as_path(), &source)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
MarketplaceError::InvalidPlugin(format!(
|
||||
"failed to materialize plugin source: {err}"
|
||||
))
|
||||
})?
|
||||
.map_err(MarketplaceError::InvalidPlugin)?;
|
||||
materialized.path.clone()
|
||||
};
|
||||
if !source_path.as_path().is_dir() {
|
||||
return Err(MarketplaceError::InvalidPlugin(
|
||||
"path does not exist or is not a directory".to_string(),
|
||||
@@ -1044,6 +1110,14 @@ impl PluginsManager {
|
||||
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
|
||||
})?;
|
||||
let description = manifest.description.clone();
|
||||
let marketplace_category = plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.category.clone());
|
||||
let interface = plugin_interface_with_marketplace_category(
|
||||
manifest.interface.clone(),
|
||||
marketplace_category,
|
||||
);
|
||||
let resolved_skills = load_plugin_skills(
|
||||
&source_path,
|
||||
&manifest.paths,
|
||||
@@ -1067,13 +1141,14 @@ impl PluginsManager {
|
||||
description,
|
||||
source: plugin.source,
|
||||
policy: plugin.policy,
|
||||
interface: plugin.interface,
|
||||
interface,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
skills: resolved_skills.skills,
|
||||
disabled_skill_paths: resolved_skills.disabled_skill_paths,
|
||||
apps,
|
||||
mcp_server_names,
|
||||
details_unavailable_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,31 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn init_git_repo(repo: &Path) {
|
||||
run_git(repo, &["init"]);
|
||||
run_git(repo, &["config", "user.email", "codex-test@example.com"]);
|
||||
run_git(repo, &["config", "user.name", "Codex Test"]);
|
||||
run_git(repo, &["add", "."]);
|
||||
run_git(repo, &["commit", "-m", "initial"]);
|
||||
}
|
||||
|
||||
fn run_git(repo: &Path, args: &[&str]) {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap_or_else(|err| panic!("git should run: {err}"));
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git -C {} {} failed\nstdout:\n{}\nstderr:\n{}",
|
||||
repo.display(),
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
|
||||
let mut root = toml::map::Map::new();
|
||||
|
||||
@@ -1050,6 +1075,113 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_plugin_supports_git_subdir_marketplace_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("marketplace");
|
||||
let remote_repo = tmp.path().join("remote-plugin-repo");
|
||||
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
|
||||
init_git_repo(&remote_repo);
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "toolkit",
|
||||
"source": {{
|
||||
"source": "git-subdir",
|
||||
"url": "{remote_repo_url}",
|
||||
"path": "plugins/toolkit"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "toolkit".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallOutcome {
|
||||
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_plugin_supports_relative_git_subdir_marketplace_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("marketplace");
|
||||
let remote_repo = repo_root.join("remote-plugin-repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
|
||||
init_git_repo(&remote_repo);
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "toolkit",
|
||||
"source": {
|
||||
"source": "git-subdir",
|
||||
"url": "./remote-plugin-repo",
|
||||
"path": "plugins/toolkit"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "toolkit".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallOutcome {
|
||||
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uninstall_plugin_removes_cache_and_config_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1433,6 +1565,179 @@ enabled = false
|
||||
assert!(outcome.plugin.disabled_skill_paths.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
|
||||
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
&format!(
|
||||
r#"{{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "toolkit",
|
||||
"source": {{
|
||||
"source": "git-subdir",
|
||||
"url": "{missing_remote_repo_url}",
|
||||
"path": "plugins/toolkit"
|
||||
}},
|
||||
"policy": {{
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.read_plugin_for_config(
|
||||
&config,
|
||||
&PluginReadRequest {
|
||||
plugin_name: "toolkit".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugin.details_unavailable_reason,
|
||||
Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource)
|
||||
);
|
||||
assert!(!outcome.plugin.installed);
|
||||
assert!(outcome.plugin.description.is_none());
|
||||
assert!(outcome.plugin.skills.is_empty());
|
||||
assert!(outcome.plugin.apps.is_empty());
|
||||
assert!(outcome.plugin.mcp_server_names.is_empty());
|
||||
assert!(
|
||||
!tmp.path()
|
||||
.join("plugins/.marketplace-plugin-source-staging")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
|
||||
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
&format!(
|
||||
r#"{{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "toolkit",
|
||||
"source": {{
|
||||
"source": "git-subdir",
|
||||
"url": "{missing_remote_repo_url}",
|
||||
"path": "plugins/toolkit"
|
||||
}},
|
||||
"category": "Developer Tools"
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local");
|
||||
write_file(
|
||||
&cached_plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "toolkit",
|
||||
"description": "Cached toolkit plugin",
|
||||
"interface": {
|
||||
"displayName": "Toolkit"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&cached_plugin_root.join("skills/search/SKILL.md"),
|
||||
"---\nname: search\ndescription: search cached data\n---\n",
|
||||
);
|
||||
write_file(
|
||||
&cached_plugin_root.join(".app.json"),
|
||||
r#"{"apps":{"calendar":{"id":"connector_calendar"}}}"#,
|
||||
);
|
||||
write_file(
|
||||
&cached_plugin_root.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#,
|
||||
);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."toolkit@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.read_plugin_for_config(
|
||||
&config,
|
||||
&PluginReadRequest {
|
||||
plugin_name: "toolkit".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.plugin.details_unavailable_reason, None);
|
||||
assert_eq!(
|
||||
outcome.plugin.description.as_deref(),
|
||||
Some("Cached toolkit plugin")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugin.interface,
|
||||
Some(PluginManifestInterface {
|
||||
display_name: Some("Toolkit".to_string()),
|
||||
category: Some("Developer Tools".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
assert!(outcome.plugin.installed);
|
||||
assert_eq!(outcome.plugin.skills.len(), 1);
|
||||
assert_eq!(outcome.plugin.skills[0].name, "toolkit:search");
|
||||
assert_eq!(
|
||||
outcome.plugin.apps,
|
||||
vec![AppConnectorId("connector_calendar".to_string())]
|
||||
);
|
||||
assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]);
|
||||
assert!(
|
||||
!tmp.path()
|
||||
.join("plugins/.marketplace-plugin-source-staging")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2656,6 +2961,65 @@ enabled = true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_non_curated_plugin_cache_refreshes_configured_git_source() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let remote_repo = tmp.path().join("remote-plugin-repo");
|
||||
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
write_plugin_with_version(
|
||||
&remote_repo,
|
||||
"plugins/sample-plugin",
|
||||
"sample-plugin",
|
||||
Some("1.2.3"),
|
||||
);
|
||||
init_git_repo(&remote_repo);
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
&format!(
|
||||
r#"{{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "sample-plugin",
|
||||
"source": {{
|
||||
"source": "git-subdir",
|
||||
"url": "{remote_repo_url}",
|
||||
"path": "plugins/sample-plugin"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample-plugin@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
|
||||
assert!(
|
||||
refresh_non_curated_plugin_cache(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
)
|
||||
.expect("cache refresh should materialize configured Git plugin")
|
||||
);
|
||||
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join("plugins/cache/debug/sample-plugin/1.2.3")
|
||||
.is_dir()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -35,6 +35,7 @@ pub use manager::ConfiguredMarketplacePlugin;
|
||||
pub use manager::OPENAI_BUNDLED_MARKETPLACE_NAME;
|
||||
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
pub use manager::PluginDetail;
|
||||
pub use manager::PluginDetailsUnavailableReason;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallOutcome;
|
||||
pub use manager::PluginInstallRequest;
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_exec_server::LOCAL_FS;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_thread_store::ListThreadsParams;
|
||||
use codex_thread_store::SortDirection;
|
||||
use codex_thread_store::StoredThread;
|
||||
use codex_thread_store::ThreadSortKey;
|
||||
use codex_thread_store::ThreadStore;
|
||||
@@ -132,6 +133,7 @@ async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
|
||||
page_size: MAX_RECENT_THREADS,
|
||||
cursor: None,
|
||||
sort_key: ThreadSortKey::UpdatedAt,
|
||||
sort_direction: SortDirection::Desc,
|
||||
allowed_sources: Vec::new(),
|
||||
model_providers: None,
|
||||
archived: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub use codex_rollout::RolloutRecorder;
|
||||
pub use codex_rollout::RolloutRecorderParams;
|
||||
pub use codex_rollout::SESSIONS_SUBDIR;
|
||||
pub use codex_rollout::SessionMeta;
|
||||
pub use codex_rollout::SortDirection;
|
||||
pub use codex_rollout::ThreadItem;
|
||||
pub use codex_rollout::ThreadSortKey;
|
||||
pub use codex_rollout::ThreadsPage;
|
||||
|
||||
@@ -1389,37 +1389,44 @@ impl Session {
|
||||
&self,
|
||||
updates: SessionSettingsUpdate,
|
||||
) -> ConstraintResult<()> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
match state.session_configuration.apply(&updates) {
|
||||
Ok(updated) => {
|
||||
let previous_cwd = state.session_configuration.cwd.clone();
|
||||
let sandbox_policy_changed =
|
||||
state.session_configuration.sandbox_policy != updated.sandbox_policy;
|
||||
let next_cwd = updated.cwd.clone();
|
||||
let codex_home = updated.codex_home.clone();
|
||||
let session_source = updated.session_source.clone();
|
||||
state.session_configuration = updated;
|
||||
drop(state);
|
||||
|
||||
self.maybe_refresh_shell_snapshot_for_cwd(
|
||||
&previous_cwd,
|
||||
&next_cwd,
|
||||
&codex_home,
|
||||
&session_source,
|
||||
);
|
||||
if sandbox_policy_changed {
|
||||
self.refresh_managed_network_proxy_for_current_sandbox_policy()
|
||||
.await;
|
||||
let (previous_cwd, sandbox_policy_changed, next_cwd, codex_home, session_source) = {
|
||||
let mut state = self.state.lock().await;
|
||||
let updated = match state.session_configuration.apply(&updates) {
|
||||
Ok(updated) => updated,
|
||||
Err(err) => {
|
||||
warn!("rejected session settings update: {err}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("rejected session settings update: {err}");
|
||||
Err(err)
|
||||
}
|
||||
let previous_cwd = state.session_configuration.cwd.clone();
|
||||
let sandbox_policy_changed =
|
||||
state.session_configuration.sandbox_policy != updated.sandbox_policy;
|
||||
let next_cwd = updated.cwd.clone();
|
||||
let codex_home = updated.codex_home.clone();
|
||||
let session_source = updated.session_source.clone();
|
||||
state.session_configuration = updated;
|
||||
(
|
||||
previous_cwd,
|
||||
sandbox_policy_changed,
|
||||
next_cwd,
|
||||
codex_home,
|
||||
session_source,
|
||||
)
|
||||
};
|
||||
|
||||
self.maybe_refresh_shell_snapshot_for_cwd(
|
||||
&previous_cwd,
|
||||
&next_cwd,
|
||||
&codex_home,
|
||||
&session_source,
|
||||
);
|
||||
if sandbox_policy_changed {
|
||||
self.refresh_managed_network_proxy_for_current_sandbox_policy()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_session_startup_prewarm(
|
||||
|
||||
@@ -2070,6 +2070,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
balance: Some("10.00".to_string()),
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
state.set_rate_limits(initial.clone());
|
||||
|
||||
@@ -2088,6 +2089,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
}),
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
state.set_rate_limits(update.clone());
|
||||
|
||||
@@ -2100,6 +2102,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
plan_type: initial.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -2176,6 +2179,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
balance: Some("15.00".to_string()),
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
state.set_rate_limits(initial.clone());
|
||||
|
||||
@@ -2190,6 +2194,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: Some(codex_protocol::account::PlanType::Pro),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
state.set_rate_limits(update.clone());
|
||||
|
||||
@@ -2202,6 +2207,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
secondary: update.secondary,
|
||||
credits: initial.credits,
|
||||
plan_type: update.plan_type,
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,13 +446,7 @@ impl Session {
|
||||
sub_id: String,
|
||||
updates: SessionSettingsUpdate,
|
||||
) -> ConstraintResult<Arc<TurnContext>> {
|
||||
let (
|
||||
session_configuration,
|
||||
sandbox_policy_changed,
|
||||
previous_cwd,
|
||||
codex_home,
|
||||
session_source,
|
||||
) = {
|
||||
let update_result = {
|
||||
let mut state = self.state.lock().await;
|
||||
match state.session_configuration.clone().apply(&updates) {
|
||||
Ok(next) => {
|
||||
@@ -462,26 +456,36 @@ impl Session {
|
||||
let codex_home = next.codex_home.clone();
|
||||
let session_source = next.session_source.clone();
|
||||
state.session_configuration = next.clone();
|
||||
(
|
||||
Ok((
|
||||
next,
|
||||
sandbox_policy_changed,
|
||||
previous_cwd,
|
||||
codex_home,
|
||||
session_source,
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
drop(state);
|
||||
self.send_event_raw(Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: err.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return Err(err);
|
||||
))
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
};
|
||||
|
||||
let (
|
||||
session_configuration,
|
||||
sandbox_policy_changed,
|
||||
previous_cwd,
|
||||
codex_home,
|
||||
session_source,
|
||||
) = match update_result {
|
||||
Ok(update) => update,
|
||||
Err(err) => {
|
||||
self.send_event_raw(Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: err.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
@@ -114,6 +115,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
@@ -126,6 +128,7 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
@@ -157,6 +160,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
|
||||
balance: Some("50".to_string()),
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
|
||||
state.set_rate_limits(RateLimitSnapshot {
|
||||
@@ -170,6 +174,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
@@ -189,6 +194,7 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
|
||||
balance: Some("50".to_string()),
|
||||
}),
|
||||
plan_type: Some(codex_protocol::account::PlanType::Plus),
|
||||
rate_limit_reached_type: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -884,9 +884,8 @@ impl JsReplManager {
|
||||
|
||||
let (req_id, rx) = {
|
||||
let req_id = Uuid::new_v4().to_string();
|
||||
let mut pending = pending_execs.lock().await;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
pending.insert(req_id.clone(), tx);
|
||||
pending_execs.lock().await.insert(req_id.clone(), tx);
|
||||
exec_contexts.lock().await.insert(
|
||||
req_id.clone(),
|
||||
ExecContext {
|
||||
@@ -956,9 +955,7 @@ impl JsReplManager {
|
||||
let response = match tokio::time::timeout(Duration::from_millis(timeout_ms), rx).await {
|
||||
Ok(Ok(msg)) => msg,
|
||||
Ok(Err(_)) => {
|
||||
let mut pending = pending_execs.lock().await;
|
||||
let removed = pending.remove(&req_id).is_some();
|
||||
drop(pending);
|
||||
let removed = pending_execs.lock().await.remove(&req_id).is_some();
|
||||
if removed {
|
||||
self.clear_top_level_exec_if_matches(&req_id).await;
|
||||
}
|
||||
@@ -1340,40 +1337,40 @@ impl JsReplManager {
|
||||
KernelToHost::EmitImage(req) => {
|
||||
let exec_id = req.exec_id.clone();
|
||||
let emit_id = req.id.clone();
|
||||
let response =
|
||||
if let Some(ctx) = exec_contexts.lock().await.get(&exec_id).cloned() {
|
||||
match validate_emitted_image_url(&req.image_url) {
|
||||
Ok(()) => {
|
||||
let content_item = emitted_image_content_item(
|
||||
ctx.turn.as_ref(),
|
||||
req.image_url,
|
||||
req.detail,
|
||||
);
|
||||
JsReplManager::record_exec_content_item(
|
||||
&exec_tool_calls,
|
||||
&exec_id,
|
||||
content_item,
|
||||
)
|
||||
.await;
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(error) => HostToKernel::EmitImageResult(EmitImageResult {
|
||||
let context = exec_contexts.lock().await.get(&exec_id).cloned();
|
||||
let response = if let Some(ctx) = context {
|
||||
match validate_emitted_image_url(&req.image_url) {
|
||||
Ok(()) => {
|
||||
let content_item = emitted_image_content_item(
|
||||
ctx.turn.as_ref(),
|
||||
req.image_url,
|
||||
req.detail,
|
||||
);
|
||||
JsReplManager::record_exec_content_item(
|
||||
&exec_tool_calls,
|
||||
&exec_id,
|
||||
content_item,
|
||||
)
|
||||
.await;
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: false,
|
||||
error: Some(error),
|
||||
}),
|
||||
ok: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
Err(error) => HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: false,
|
||||
error: Some("js_repl exec context not found".to_string()),
|
||||
})
|
||||
};
|
||||
error: Some(error),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
HostToKernel::EmitImageResult(EmitImageResult {
|
||||
id: emit_id,
|
||||
ok: false,
|
||||
error: Some("js_repl exec context not found".to_string()),
|
||||
})
|
||||
};
|
||||
|
||||
if let Err(err) = JsReplManager::write_message(&stdin, &response).await {
|
||||
let snapshot =
|
||||
@@ -1424,7 +1421,7 @@ impl JsReplManager {
|
||||
let exec_id = req.exec_id.clone();
|
||||
let tool_call_id = req.id.clone();
|
||||
let tool_name = req.tool_name.clone();
|
||||
let context = { exec_contexts.lock().await.get(&exec_id).cloned() };
|
||||
let context = exec_contexts.lock().await.get(&exec_id).cloned();
|
||||
let result = match context {
|
||||
Some(ctx) => {
|
||||
tokio::select! {
|
||||
@@ -1502,14 +1499,17 @@ impl JsReplManager {
|
||||
}
|
||||
}
|
||||
|
||||
let mut pending = pending_execs.lock().await;
|
||||
let pending_exec_ids = pending.keys().cloned().collect::<Vec<_>>();
|
||||
for (_id, tx) in pending.drain() {
|
||||
let pending_execs_to_notify = {
|
||||
let mut pending = pending_execs.lock().await;
|
||||
pending.drain().collect::<Vec<_>>()
|
||||
};
|
||||
let mut pending_exec_ids = Vec::with_capacity(pending_execs_to_notify.len());
|
||||
for (id, tx) in pending_execs_to_notify {
|
||||
pending_exec_ids.push(id);
|
||||
let _ = tx.send(ExecResultMessage::Err {
|
||||
message: kernel_exit_message.clone(),
|
||||
});
|
||||
}
|
||||
drop(pending);
|
||||
if !pending_exec_ids.is_empty() {
|
||||
Self::clear_top_level_exec_if_matches_any_map(&manager_kernel, &pending_exec_ids).await;
|
||||
}
|
||||
|
||||
@@ -223,10 +223,8 @@ impl NetworkApprovalService {
|
||||
}
|
||||
|
||||
pub(crate) async fn unregister_call(&self, registration_id: &str) {
|
||||
let mut active_calls = self.active_calls.lock().await;
|
||||
active_calls.shift_remove(registration_id);
|
||||
let mut call_outcomes = self.call_outcomes.lock().await;
|
||||
call_outcomes.remove(registration_id);
|
||||
self.active_calls.lock().await.shift_remove(registration_id);
|
||||
self.call_outcomes.lock().await.remove(registration_id);
|
||||
}
|
||||
|
||||
async fn resolve_single_active_call(&self) -> Option<Arc<ActiveNetworkApprovalCall>> {
|
||||
@@ -344,8 +342,7 @@ impl NetworkApprovalService {
|
||||
|
||||
let Some(turn_context) = Self::active_turn_context(session.as_ref()).await else {
|
||||
pending.set_decision(PendingApprovalDecision::Deny).await;
|
||||
let mut pending_approvals = self.pending_host_approvals.lock().await;
|
||||
pending_approvals.remove(&key);
|
||||
self.pending_host_approvals.lock().await.remove(&key);
|
||||
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
|
||||
policy_denial_message,
|
||||
))
|
||||
@@ -354,8 +351,7 @@ impl NetworkApprovalService {
|
||||
};
|
||||
if !sandbox_policy_allows_network_approval_flow(turn_context.sandbox_policy.get()) {
|
||||
pending.set_decision(PendingApprovalDecision::Deny).await;
|
||||
let mut pending_approvals = self.pending_host_approvals.lock().await;
|
||||
pending_approvals.remove(&key);
|
||||
self.pending_host_approvals.lock().await.remove(&key);
|
||||
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
|
||||
policy_denial_message,
|
||||
))
|
||||
@@ -364,8 +360,7 @@ impl NetworkApprovalService {
|
||||
}
|
||||
if !allows_network_approval_flow(turn_context.approval_policy.value()) {
|
||||
pending.set_decision(PendingApprovalDecision::Deny).await;
|
||||
let mut pending_approvals = self.pending_host_approvals.lock().await;
|
||||
pending_approvals.remove(&key);
|
||||
self.pending_host_approvals.lock().await.remove(&key);
|
||||
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
|
||||
policy_denial_message,
|
||||
))
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::process::UnifiedExecProcess;
|
||||
use crate::unified_exec::UnifiedExecError;
|
||||
use async_trait::async_trait;
|
||||
use codex_exec_server::ExecProcess;
|
||||
use codex_exec_server::ExecProcessEventReceiver;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::ProcessId;
|
||||
use codex_exec_server::ReadResponse;
|
||||
@@ -33,6 +34,10 @@ impl ExecProcess for MockExecProcess {
|
||||
self.wake_tx.subscribe()
|
||||
}
|
||||
|
||||
fn subscribe_events(&self) -> ExecProcessEventReceiver {
|
||||
ExecProcessEventReceiver::empty()
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
_after_seq: Option<u64>,
|
||||
|
||||
@@ -2375,7 +2375,8 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
"resets_at": 1704074400
|
||||
},
|
||||
"credits": null,
|
||||
"plan_type": null
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -2426,7 +2427,8 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
"resets_at": 1704074400
|
||||
},
|
||||
"credits": null,
|
||||
"plan_type": null
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -2500,7 +2502,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
"resets_at": null
|
||||
},
|
||||
"credits": null,
|
||||
"plan_type": null
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
});
|
||||
|
||||
let submission_id = codex
|
||||
|
||||
@@ -1033,7 +1033,8 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
|
||||
"resets_at": null
|
||||
},
|
||||
"credits": null,
|
||||
"plan_type": null
|
||||
"plan_type": null,
|
||||
"rate_limit_reached_type": null
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -178,6 +178,7 @@ impl AppServerClient {
|
||||
cursor,
|
||||
limit: None,
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
@@ -16,6 +18,9 @@ use crate::ProcessId;
|
||||
use crate::client_api::ExecServerClientConnectOptions;
|
||||
use crate::client_api::RemoteExecServerConnectArgs;
|
||||
use crate::connection::JsonRpcConnection;
|
||||
use crate::process::ExecProcessEvent;
|
||||
use crate::process::ExecProcessEventLog;
|
||||
use crate::process::ExecProcessEventReceiver;
|
||||
use crate::protocol::EXEC_CLOSED_METHOD;
|
||||
use crate::protocol::EXEC_EXITED_METHOD;
|
||||
use crate::protocol::EXEC_METHOD;
|
||||
@@ -53,6 +58,7 @@ use crate::protocol::INITIALIZE_METHOD;
|
||||
use crate::protocol::INITIALIZED_METHOD;
|
||||
use crate::protocol::InitializeParams;
|
||||
use crate::protocol::InitializeResponse;
|
||||
use crate::protocol::ProcessOutputChunk;
|
||||
use crate::protocol::ReadParams;
|
||||
use crate::protocol::ReadResponse;
|
||||
use crate::protocol::TerminateParams;
|
||||
@@ -65,6 +71,8 @@ use crate::rpc::RpcClientEvent;
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PROCESS_EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
const PROCESS_EVENT_RETAINED_BYTES: usize = 1024 * 1024;
|
||||
|
||||
impl Default for ExecServerClientConnectOptions {
|
||||
fn default() -> Self {
|
||||
@@ -100,9 +108,20 @@ impl RemoteExecServerConnectArgs {
|
||||
|
||||
pub(crate) struct SessionState {
|
||||
wake_tx: watch::Sender<u64>,
|
||||
events: ExecProcessEventLog,
|
||||
ordered_events: StdMutex<OrderedSessionEvents>,
|
||||
failure: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OrderedSessionEvents {
|
||||
last_published_seq: u64,
|
||||
// Server-side output, exit, and closed notifications are emitted by
|
||||
// different tasks and can reach the client out of order. Keep future events
|
||||
// here until all lower sequence numbers have been published.
|
||||
pending: BTreeMap<u64, ExecProcessEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Session {
|
||||
client: ExecServerClient,
|
||||
@@ -452,6 +471,11 @@ impl SessionState {
|
||||
let (wake_tx, _wake_rx) = watch::channel(0);
|
||||
Self {
|
||||
wake_tx,
|
||||
events: ExecProcessEventLog::new(
|
||||
PROCESS_EVENT_CHANNEL_CAPACITY,
|
||||
PROCESS_EVENT_RETAINED_BYTES,
|
||||
),
|
||||
ordered_events: StdMutex::new(OrderedSessionEvents::default()),
|
||||
failure: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
@@ -460,19 +484,71 @@ impl SessionState {
|
||||
self.wake_tx.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_events(&self) -> ExecProcessEventReceiver {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
fn note_change(&self, seq: u64) {
|
||||
let next = (*self.wake_tx.borrow()).max(seq);
|
||||
let _ = self.wake_tx.send(next);
|
||||
}
|
||||
|
||||
/// Publishes a process event only when all earlier sequenced events have
|
||||
/// already been published.
|
||||
///
|
||||
/// Returns `true` only when this call actually publishes the ordered
|
||||
/// `Closed` event. The caller uses that signal to remove the session route
|
||||
/// after the terminal event is visible to subscribers, rather than when a
|
||||
/// possibly-early closed notification first arrives.
|
||||
fn publish_ordered_event(&self, event: ExecProcessEvent) -> bool {
|
||||
let Some(seq) = event.seq() else {
|
||||
self.events.publish(event);
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut ready = Vec::new();
|
||||
{
|
||||
let mut ordered_events = self
|
||||
.ordered_events
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// We have already delivered this sequence number or moved past it,
|
||||
// so accepting it again would duplicate output or lifecycle events.
|
||||
if seq <= ordered_events.last_published_seq {
|
||||
return false;
|
||||
}
|
||||
|
||||
ordered_events.pending.entry(seq).or_insert(event);
|
||||
loop {
|
||||
let next_seq = ordered_events.last_published_seq + 1;
|
||||
let Some(event) = ordered_events.pending.remove(&next_seq) else {
|
||||
break;
|
||||
};
|
||||
ordered_events.last_published_seq += 1;
|
||||
ready.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
let mut published_closed = false;
|
||||
for event in ready {
|
||||
published_closed |= matches!(&event, ExecProcessEvent::Closed { .. });
|
||||
self.events.publish(event);
|
||||
}
|
||||
published_closed
|
||||
}
|
||||
|
||||
async fn set_failure(&self, message: String) {
|
||||
let mut failure = self.failure.lock().await;
|
||||
if failure.is_none() {
|
||||
*failure = Some(message);
|
||||
let should_publish = failure.is_none();
|
||||
if should_publish {
|
||||
*failure = Some(message.clone());
|
||||
}
|
||||
drop(failure);
|
||||
let next = (*self.wake_tx.borrow()).saturating_add(1);
|
||||
let _ = self.wake_tx.send(next);
|
||||
if should_publish {
|
||||
let _ = self.publish_ordered_event(ExecProcessEvent::Failed(message));
|
||||
}
|
||||
}
|
||||
|
||||
async fn failed_response(&self) -> Option<ReadResponse> {
|
||||
@@ -505,6 +581,10 @@ impl Session {
|
||||
self.state.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_events(&self) -> ExecProcessEventReceiver {
|
||||
self.state.subscribe_events()
|
||||
}
|
||||
|
||||
pub(crate) async fn read(
|
||||
&self,
|
||||
after_seq: Option<u64>,
|
||||
@@ -628,6 +708,15 @@ async fn handle_server_notification(
|
||||
serde_json::from_value(notification.params.unwrap_or(Value::Null))?;
|
||||
if let Some(session) = inner.get_session(¶ms.process_id) {
|
||||
session.note_change(params.seq);
|
||||
let published_closed =
|
||||
session.publish_ordered_event(ExecProcessEvent::Output(ProcessOutputChunk {
|
||||
seq: params.seq,
|
||||
stream: params.stream,
|
||||
chunk: params.chunk,
|
||||
}));
|
||||
if published_closed {
|
||||
inner.remove_session(¶ms.process_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXEC_EXITED_METHOD => {
|
||||
@@ -635,16 +724,28 @@ async fn handle_server_notification(
|
||||
serde_json::from_value(notification.params.unwrap_or(Value::Null))?;
|
||||
if let Some(session) = inner.get_session(¶ms.process_id) {
|
||||
session.note_change(params.seq);
|
||||
let published_closed = session.publish_ordered_event(ExecProcessEvent::Exited {
|
||||
seq: params.seq,
|
||||
exit_code: params.exit_code,
|
||||
});
|
||||
if published_closed {
|
||||
inner.remove_session(¶ms.process_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXEC_CLOSED_METHOD => {
|
||||
let params: ExecClosedNotification =
|
||||
serde_json::from_value(notification.params.unwrap_or(Value::Null))?;
|
||||
// Closed is the terminal lifecycle event for this process, so drop
|
||||
// the routing entry before forwarding it.
|
||||
let session = inner.remove_session(¶ms.process_id).await;
|
||||
if let Some(session) = session {
|
||||
if let Some(session) = inner.get_session(¶ms.process_id) {
|
||||
session.note_change(params.seq);
|
||||
// Closed is terminal, but it can arrive before tail output or
|
||||
// exited. Keep routing this process until the ordered publisher
|
||||
// says Closed has actually been delivered.
|
||||
let published_closed =
|
||||
session.publish_ordered_event(ExecProcessEvent::Closed { seq: params.seq });
|
||||
if published_closed {
|
||||
inner.remove_session(¶ms.process_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
@@ -673,14 +774,18 @@ mod tests {
|
||||
use super::ExecServerClientConnectOptions;
|
||||
use crate::ProcessId;
|
||||
use crate::connection::JsonRpcConnection;
|
||||
use crate::process::ExecProcessEvent;
|
||||
use crate::protocol::EXEC_CLOSED_METHOD;
|
||||
use crate::protocol::EXEC_EXITED_METHOD;
|
||||
use crate::protocol::EXEC_OUTPUT_DELTA_METHOD;
|
||||
use crate::protocol::ExecClosedNotification;
|
||||
use crate::protocol::ExecExitedNotification;
|
||||
use crate::protocol::ExecOutputDeltaNotification;
|
||||
use crate::protocol::ExecOutputStream;
|
||||
use crate::protocol::INITIALIZE_METHOD;
|
||||
use crate::protocol::INITIALIZED_METHOD;
|
||||
use crate::protocol::InitializeResponse;
|
||||
use crate::protocol::ProcessOutputChunk;
|
||||
|
||||
async fn read_jsonrpc_line<R>(lines: &mut tokio::io::Lines<BufReader<R>>) -> JSONRPCMessage
|
||||
where
|
||||
@@ -705,6 +810,149 @@ mod tests {
|
||||
.expect("json-rpc line should write");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_events_are_delivered_in_seq_order_when_notifications_are_reordered() {
|
||||
let (client_stdin, server_reader) = duplex(1 << 20);
|
||||
let (mut server_writer, client_stdout) = duplex(1 << 20);
|
||||
let (notifications_tx, mut notifications_rx) = mpsc::channel(16);
|
||||
let server = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(server_reader).lines();
|
||||
let initialize = read_jsonrpc_line(&mut lines).await;
|
||||
let request = match initialize {
|
||||
JSONRPCMessage::Request(request) if request.method == INITIALIZE_METHOD => request,
|
||||
other => panic!("expected initialize request, got {other:?}"),
|
||||
};
|
||||
write_jsonrpc_line(
|
||||
&mut server_writer,
|
||||
JSONRPCMessage::Response(JSONRPCResponse {
|
||||
id: request.id,
|
||||
result: serde_json::to_value(InitializeResponse {
|
||||
session_id: "session-1".to_string(),
|
||||
})
|
||||
.expect("initialize response should serialize"),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let initialized = read_jsonrpc_line(&mut lines).await;
|
||||
match initialized {
|
||||
JSONRPCMessage::Notification(notification)
|
||||
if notification.method == INITIALIZED_METHOD => {}
|
||||
other => panic!("expected initialized notification, got {other:?}"),
|
||||
}
|
||||
|
||||
while let Some(message) = notifications_rx.recv().await {
|
||||
write_jsonrpc_line(&mut server_writer, message).await;
|
||||
}
|
||||
});
|
||||
|
||||
let client = ExecServerClient::connect(
|
||||
JsonRpcConnection::from_stdio(
|
||||
client_stdout,
|
||||
client_stdin,
|
||||
"test-exec-server-client".to_string(),
|
||||
),
|
||||
ExecServerClientConnectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("client should connect");
|
||||
|
||||
let process_id = ProcessId::from("reordered");
|
||||
let session = client
|
||||
.register_session(&process_id)
|
||||
.await
|
||||
.expect("session should register");
|
||||
let mut events = session.subscribe_events();
|
||||
|
||||
for message in [
|
||||
JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: EXEC_CLOSED_METHOD.to_string(),
|
||||
params: Some(
|
||||
serde_json::to_value(ExecClosedNotification {
|
||||
process_id: process_id.clone(),
|
||||
seq: 4,
|
||||
})
|
||||
.expect("closed notification should serialize"),
|
||||
),
|
||||
}),
|
||||
JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: EXEC_OUTPUT_DELTA_METHOD.to_string(),
|
||||
params: Some(
|
||||
serde_json::to_value(ExecOutputDeltaNotification {
|
||||
process_id: process_id.clone(),
|
||||
seq: 1,
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: b"one".to_vec().into(),
|
||||
})
|
||||
.expect("output notification should serialize"),
|
||||
),
|
||||
}),
|
||||
JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: EXEC_EXITED_METHOD.to_string(),
|
||||
params: Some(
|
||||
serde_json::to_value(ExecExitedNotification {
|
||||
process_id: process_id.clone(),
|
||||
seq: 3,
|
||||
exit_code: 0,
|
||||
})
|
||||
.expect("exit notification should serialize"),
|
||||
),
|
||||
}),
|
||||
JSONRPCMessage::Notification(JSONRPCNotification {
|
||||
method: EXEC_OUTPUT_DELTA_METHOD.to_string(),
|
||||
params: Some(
|
||||
serde_json::to_value(ExecOutputDeltaNotification {
|
||||
process_id: process_id.clone(),
|
||||
seq: 2,
|
||||
stream: ExecOutputStream::Stderr,
|
||||
chunk: b"two".to_vec().into(),
|
||||
})
|
||||
.expect("output notification should serialize"),
|
||||
),
|
||||
}),
|
||||
] {
|
||||
notifications_tx
|
||||
.send(message)
|
||||
.await
|
||||
.expect("notification should queue");
|
||||
}
|
||||
|
||||
let mut delivered = Vec::new();
|
||||
for _ in 0..4 {
|
||||
delivered.push(
|
||||
timeout(Duration::from_secs(1), events.recv())
|
||||
.await
|
||||
.expect("process event should not time out")
|
||||
.expect("process event stream should stay open"),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
delivered,
|
||||
vec![
|
||||
ExecProcessEvent::Output(ProcessOutputChunk {
|
||||
seq: 1,
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: b"one".to_vec().into(),
|
||||
}),
|
||||
ExecProcessEvent::Output(ProcessOutputChunk {
|
||||
seq: 2,
|
||||
stream: ExecOutputStream::Stderr,
|
||||
chunk: b"two".to_vec().into(),
|
||||
}),
|
||||
ExecProcessEvent::Exited {
|
||||
seq: 3,
|
||||
exit_code: 0,
|
||||
},
|
||||
ExecProcessEvent::Closed { seq: 4 },
|
||||
]
|
||||
);
|
||||
|
||||
drop(notifications_tx);
|
||||
drop(client);
|
||||
server.await.expect("server task should finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wake_notifications_do_not_block_other_sessions() {
|
||||
let (client_stdin, server_reader) = duplex(1 << 20);
|
||||
|
||||
@@ -27,14 +27,20 @@ use crate::local_file_system::current_sandbox_cwd;
|
||||
use crate::rpc::internal_error;
|
||||
use crate::rpc::invalid_request;
|
||||
|
||||
const FS_HELPER_ENV_ALLOWLIST: &[&str] = &["PATH", "TMPDIR", "TMP", "TEMP"];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FileSystemSandboxRunner {
|
||||
runtime_paths: ExecServerRuntimePaths,
|
||||
helper_env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl FileSystemSandboxRunner {
|
||||
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self { runtime_paths }
|
||||
Self {
|
||||
runtime_paths,
|
||||
helper_env: helper_env(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
@@ -85,7 +91,7 @@ impl FileSystemSandboxRunner {
|
||||
program: helper.as_path().as_os_str().to_owned(),
|
||||
args: vec![CODEX_FS_HELPER_ARG1.to_string()],
|
||||
cwd: cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
env: self.helper_env.clone(),
|
||||
additional_permissions: Some(
|
||||
self.helper_permissions(sandbox_context.additional_permissions.as_ref()),
|
||||
),
|
||||
@@ -195,6 +201,26 @@ fn normalize_top_level_alias(path: AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
fn helper_env() -> HashMap<String, String> {
|
||||
helper_env_from_vars(std::env::vars_os())
|
||||
}
|
||||
|
||||
fn helper_env_from_vars(
|
||||
vars: impl IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
|
||||
) -> HashMap<String, String> {
|
||||
vars.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let key = key.to_string_lossy();
|
||||
helper_env_key_is_allowed(&key)
|
||||
.then(|| (key.into_owned(), value.to_string_lossy().into_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn helper_env_key_is_allowed(key: &str) -> bool {
|
||||
FS_HELPER_ENV_ALLOWLIST.contains(&key) || (cfg!(windows) && key.eq_ignore_ascii_case("PATH"))
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
command: SandboxExecRequest,
|
||||
request_json: Vec<u8>,
|
||||
@@ -286,9 +312,14 @@ fn json_error(err: serde_json::Error) -> JSONRPCErrorError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -297,6 +328,9 @@ mod tests {
|
||||
use crate::ExecServerRuntimePaths;
|
||||
|
||||
use super::FileSystemSandboxRunner;
|
||||
use super::helper_env;
|
||||
use super::helper_env_from_vars;
|
||||
use super::helper_env_key_is_allowed;
|
||||
use super::sandbox_policy_with_helper_runtime_defaults;
|
||||
|
||||
#[test]
|
||||
@@ -396,6 +430,99 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_env_carries_only_allowlisted_runtime_vars() {
|
||||
let env = helper_env();
|
||||
|
||||
let expected = std::env::vars_os()
|
||||
.filter_map(|(key, value)| {
|
||||
let key = key.to_string_lossy();
|
||||
helper_env_key_is_allowed(&key)
|
||||
.then(|| (key.into_owned(), value.to_string_lossy().into_owned()))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
assert_eq!(env, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_env_preserves_path_for_system_bwrap_discovery_without_leaking_secrets() {
|
||||
let env = helper_env_from_vars(
|
||||
[
|
||||
("PATH", "/usr/bin:/bin"),
|
||||
("TMPDIR", "/tmp/codex"),
|
||||
("TMP", "/tmp"),
|
||||
("TEMP", "/tmp"),
|
||||
("HOME", "/home/user"),
|
||||
("OPENAI_API_KEY", "secret"),
|
||||
("HTTPS_PROXY", "http://proxy.example"),
|
||||
]
|
||||
.map(|(key, value)| (OsString::from(key), OsString::from(value))),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env,
|
||||
HashMap::from([
|
||||
("PATH".to_string(), "/usr/bin:/bin".to_string()),
|
||||
("TMPDIR".to_string(), "/tmp/codex".to_string()),
|
||||
("TMP".to_string(), "/tmp".to_string()),
|
||||
("TEMP".to_string(), "/tmp".to_string()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn helper_env_preserves_windows_path_key_for_system_bwrap_discovery() {
|
||||
let env = helper_env_from_vars(
|
||||
[
|
||||
("Path", r"C:\Windows\System32"),
|
||||
("PATH_INJECTION", "bad"),
|
||||
("OPENAI_API_KEY", "secret"),
|
||||
]
|
||||
.map(|(key, value)| (OsString::from(key), OsString::from(value))),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env,
|
||||
HashMap::from([("Path".to_string(), r"C:\Windows\System32".to_string())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_exec_request_carries_helper_env() {
|
||||
let Some((path_key, path)) = std::env::vars_os().find(|(key, _)| {
|
||||
let key = key.to_string_lossy();
|
||||
key == "PATH" || (cfg!(windows) && key.eq_ignore_ascii_case("PATH"))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let path_key = path_key.to_string_lossy().into_owned();
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let codex_self_exe = std::env::current_exe().expect("current exe");
|
||||
let runtime_paths =
|
||||
ExecServerRuntimePaths::new(codex_self_exe.clone(), Some(codex_self_exe))
|
||||
.expect("runtime paths");
|
||||
let runner = FileSystemSandboxRunner::new(runtime_paths);
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
||||
let file_system_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path());
|
||||
let sandbox_context = crate::FileSystemSandboxContext::new(sandbox_policy.clone());
|
||||
|
||||
let request = runner
|
||||
.sandbox_exec_request(
|
||||
&sandbox_policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
&cwd,
|
||||
&sandbox_context,
|
||||
)
|
||||
.expect("sandbox exec request");
|
||||
|
||||
assert_eq!(request.env.get(&path_key), Some(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_permissions_include_helper_read_root_without_additional_permissions() {
|
||||
let codex_self_exe = std::env::current_exe().expect("current exe");
|
||||
|
||||
@@ -39,6 +39,8 @@ pub use local_file_system::LOCAL_FS;
|
||||
pub use local_file_system::LocalFileSystem;
|
||||
pub use process::ExecBackend;
|
||||
pub use process::ExecProcess;
|
||||
pub use process::ExecProcessEvent;
|
||||
pub use process::ExecProcessEventReceiver;
|
||||
pub use process::StartedExecProcess;
|
||||
pub use process_id::ProcessId;
|
||||
pub use protocol::ExecClosedNotification;
|
||||
@@ -65,6 +67,7 @@ pub use protocol::FsWriteFileParams;
|
||||
pub use protocol::FsWriteFileResponse;
|
||||
pub use protocol::InitializeParams;
|
||||
pub use protocol::InitializeResponse;
|
||||
pub use protocol::ProcessOutputChunk;
|
||||
pub use protocol::ReadParams;
|
||||
pub use protocol::ReadResponse;
|
||||
pub use protocol::TerminateParams;
|
||||
|
||||
@@ -17,9 +17,12 @@ use tokio::sync::watch;
|
||||
|
||||
use crate::ExecBackend;
|
||||
use crate::ExecProcess;
|
||||
use crate::ExecProcessEvent;
|
||||
use crate::ExecProcessEventReceiver;
|
||||
use crate::ExecServerError;
|
||||
use crate::ProcessId;
|
||||
use crate::StartedExecProcess;
|
||||
use crate::process::ExecProcessEventLog;
|
||||
use crate::protocol::EXEC_CLOSED_METHOD;
|
||||
use crate::protocol::ExecClosedNotification;
|
||||
use crate::protocol::ExecEnvPolicy;
|
||||
@@ -44,6 +47,7 @@ use crate::rpc::invalid_request;
|
||||
|
||||
const RETAINED_OUTPUT_BYTES_PER_PROCESS: usize = 1024 * 1024;
|
||||
const NOTIFICATION_CHANNEL_CAPACITY: usize = 256;
|
||||
const PROCESS_EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
#[cfg(test)]
|
||||
const EXITED_PROCESS_RETENTION: Duration = Duration::from_millis(25);
|
||||
#[cfg(not(test))]
|
||||
@@ -65,6 +69,7 @@ struct RunningProcess {
|
||||
next_seq: u64,
|
||||
exit_code: Option<i32>,
|
||||
wake_tx: watch::Sender<u64>,
|
||||
events: ExecProcessEventLog,
|
||||
output_notify: Arc<Notify>,
|
||||
open_streams: usize,
|
||||
closed: bool,
|
||||
@@ -89,6 +94,7 @@ struct LocalExecProcess {
|
||||
process_id: ProcessId,
|
||||
backend: LocalProcess,
|
||||
wake_tx: watch::Sender<u64>,
|
||||
events: ExecProcessEventLog,
|
||||
}
|
||||
|
||||
impl Default for LocalProcess {
|
||||
@@ -138,7 +144,7 @@ impl LocalProcess {
|
||||
async fn start_process(
|
||||
&self,
|
||||
params: ExecParams,
|
||||
) -> Result<(ExecResponse, watch::Sender<u64>), JSONRPCErrorError> {
|
||||
) -> Result<(ExecResponse, watch::Sender<u64>, ExecProcessEventLog), JSONRPCErrorError> {
|
||||
let process_id = params.process_id.clone();
|
||||
let (program, args) = params
|
||||
.argv
|
||||
@@ -198,6 +204,10 @@ impl LocalProcess {
|
||||
|
||||
let output_notify = Arc::new(Notify::new());
|
||||
let (wake_tx, _wake_rx) = watch::channel(0);
|
||||
let events = ExecProcessEventLog::new(
|
||||
PROCESS_EVENT_CHANNEL_CAPACITY,
|
||||
RETAINED_OUTPUT_BYTES_PER_PROCESS,
|
||||
);
|
||||
{
|
||||
let mut process_map = self.inner.processes.lock().await;
|
||||
process_map.insert(
|
||||
@@ -211,6 +221,7 @@ impl LocalProcess {
|
||||
next_seq: 1,
|
||||
exit_code: None,
|
||||
wake_tx: wake_tx.clone(),
|
||||
events: events.clone(),
|
||||
output_notify: Arc::clone(&output_notify),
|
||||
open_streams: 2,
|
||||
closed: false,
|
||||
@@ -247,13 +258,13 @@ impl LocalProcess {
|
||||
output_notify,
|
||||
));
|
||||
|
||||
Ok((ExecResponse { process_id }, wake_tx))
|
||||
Ok((ExecResponse { process_id }, wake_tx, events))
|
||||
}
|
||||
|
||||
pub(crate) async fn exec(&self, params: ExecParams) -> Result<ExecResponse, JSONRPCErrorError> {
|
||||
self.start_process(params)
|
||||
.await
|
||||
.map(|(response, _)| response)
|
||||
.map(|(response, _, _)| response)
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_read(
|
||||
@@ -424,7 +435,7 @@ fn shell_environment_policy(env_policy: &ExecEnvPolicy) -> ShellEnvironmentPolic
|
||||
#[async_trait]
|
||||
impl ExecBackend for LocalProcess {
|
||||
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError> {
|
||||
let (response, wake_tx) = self
|
||||
let (response, wake_tx, events) = self
|
||||
.start_process(params)
|
||||
.await
|
||||
.map_err(map_handler_error)?;
|
||||
@@ -433,6 +444,7 @@ impl ExecBackend for LocalProcess {
|
||||
process_id: response.process_id,
|
||||
backend: self.clone(),
|
||||
wake_tx,
|
||||
events,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -448,6 +460,10 @@ impl ExecProcess for LocalExecProcess {
|
||||
self.wake_tx.subscribe()
|
||||
}
|
||||
|
||||
fn subscribe_events(&self) -> ExecProcessEventReceiver {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
after_seq: Option<u64>,
|
||||
@@ -548,11 +564,19 @@ async fn stream_output(
|
||||
process.retained_bytes = process.retained_bytes.saturating_sub(evicted.chunk.len());
|
||||
}
|
||||
let _ = process.wake_tx.send(seq);
|
||||
let output = ProcessOutputChunk {
|
||||
seq,
|
||||
stream,
|
||||
chunk: chunk.into(),
|
||||
};
|
||||
process
|
||||
.events
|
||||
.publish(ExecProcessEvent::Output(output.clone()));
|
||||
ExecOutputDeltaNotification {
|
||||
process_id: process_id.clone(),
|
||||
seq,
|
||||
stream,
|
||||
chunk: chunk.into(),
|
||||
chunk: output.chunk,
|
||||
}
|
||||
};
|
||||
output_notify.notify_waiters();
|
||||
@@ -580,6 +604,9 @@ async fn watch_exit(
|
||||
process.next_seq += 1;
|
||||
process.exit_code = Some(exit_code);
|
||||
let _ = process.wake_tx.send(seq);
|
||||
process
|
||||
.events
|
||||
.publish(ExecProcessEvent::Exited { seq, exit_code });
|
||||
Some(ExecExitedNotification {
|
||||
process_id: process_id.clone(),
|
||||
seq,
|
||||
@@ -640,6 +667,7 @@ async fn maybe_emit_closed(process_id: ProcessId, inner: Arc<Inner>) {
|
||||
let seq = process.next_seq;
|
||||
process.next_seq += 1;
|
||||
let _ = process.wake_tx.send(seq);
|
||||
process.events.publish(ExecProcessEvent::Closed { seq });
|
||||
Some(ExecClosedNotification {
|
||||
process_id: process_id.clone(),
|
||||
seq,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::ExecServerError;
|
||||
use crate::ProcessId;
|
||||
use crate::protocol::ExecParams;
|
||||
use crate::protocol::ProcessOutputChunk;
|
||||
use crate::protocol::ReadResponse;
|
||||
use crate::protocol::WriteResponse;
|
||||
|
||||
@@ -13,12 +17,158 @@ pub struct StartedExecProcess {
|
||||
pub process: Arc<dyn ExecProcess>,
|
||||
}
|
||||
|
||||
/// Pushed process events for consumers that want to follow process output as it
|
||||
/// arrives instead of polling retained output with [`ExecProcess::read`].
|
||||
///
|
||||
/// The stream is scoped to one [`ExecProcess`] handle. `Output` events carry
|
||||
/// stdout, stderr, or pty bytes. `Exited` reports the process exit status, while
|
||||
/// `Closed` means all output streams have ended and no more output events will
|
||||
/// arrive. `Failed` is used when the process session cannot continue, for
|
||||
/// example because the remote executor connection disconnected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExecProcessEvent {
|
||||
Output(ProcessOutputChunk),
|
||||
Exited { seq: u64, exit_code: i32 },
|
||||
Closed { seq: u64 },
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Replay buffer plus live fan-out for pushed process events.
|
||||
///
|
||||
/// New subscribers first drain a bounded replay history, then continue on the
|
||||
/// live broadcast channel. The history is bounded by event count and retained
|
||||
/// output bytes: count protects against many tiny events, while bytes protects
|
||||
/// against a few very large output chunks.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ExecProcessEventLog {
|
||||
inner: Arc<ExecProcessEventLogInner>,
|
||||
}
|
||||
|
||||
struct ExecProcessEventLogInner {
|
||||
history: StdMutex<ExecProcessEventHistory>,
|
||||
live_tx: broadcast::Sender<ExecProcessEvent>,
|
||||
event_capacity: usize,
|
||||
byte_capacity: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ExecProcessEventHistory {
|
||||
events: VecDeque<ExecProcessEvent>,
|
||||
retained_bytes: usize,
|
||||
}
|
||||
|
||||
impl ExecProcessEvent {
|
||||
/// Sequence number used to order process-owned events.
|
||||
///
|
||||
/// `Failed` is intentionally unsequenced because it is synthesized by the
|
||||
/// client when the session or transport fails, not emitted by the process.
|
||||
pub(crate) fn seq(&self) -> Option<u64> {
|
||||
match self {
|
||||
ExecProcessEvent::Output(chunk) => Some(chunk.seq),
|
||||
ExecProcessEvent::Exited { seq, .. } | ExecProcessEvent::Closed { seq } => Some(*seq),
|
||||
ExecProcessEvent::Failed(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn retained_len(&self) -> usize {
|
||||
match self {
|
||||
ExecProcessEvent::Output(chunk) => chunk.chunk.0.len(),
|
||||
ExecProcessEvent::Failed(message) => message.len(),
|
||||
ExecProcessEvent::Exited { .. } | ExecProcessEvent::Closed { .. } => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecProcessEventLog {
|
||||
pub(crate) fn new(event_capacity: usize, byte_capacity: usize) -> Self {
|
||||
let (live_tx, _live_rx) = broadcast::channel(event_capacity);
|
||||
Self {
|
||||
inner: Arc::new(ExecProcessEventLogInner {
|
||||
history: StdMutex::new(ExecProcessEventHistory::default()),
|
||||
live_tx,
|
||||
event_capacity,
|
||||
byte_capacity,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, event: ExecProcessEvent) {
|
||||
let mut history = self
|
||||
.inner
|
||||
.history
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
history.retained_bytes += event.retained_len();
|
||||
history.events.push_back(event.clone());
|
||||
while history.events.len() > self.inner.event_capacity
|
||||
|| history.retained_bytes > self.inner.byte_capacity
|
||||
{
|
||||
let Some(evicted) = history.events.pop_front() else {
|
||||
break;
|
||||
};
|
||||
history.retained_bytes = history
|
||||
.retained_bytes
|
||||
.saturating_sub(evicted.retained_len());
|
||||
}
|
||||
|
||||
let _ = self.inner.live_tx.send(event);
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self) -> ExecProcessEventReceiver {
|
||||
let history = self
|
||||
.inner
|
||||
.history
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let live_rx = self.inner.live_tx.subscribe();
|
||||
let replay = history.events.iter().cloned().collect();
|
||||
|
||||
ExecProcessEventReceiver { replay, live_rx }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecProcessEventReceiver {
|
||||
replay: VecDeque<ExecProcessEvent>,
|
||||
live_rx: broadcast::Receiver<ExecProcessEvent>,
|
||||
}
|
||||
|
||||
impl ExecProcessEventReceiver {
|
||||
pub fn empty() -> Self {
|
||||
let (_live_tx, live_rx) = broadcast::channel(1);
|
||||
Self {
|
||||
replay: VecDeque::new(),
|
||||
live_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the next replayed or live event.
|
||||
///
|
||||
/// `Lagged` means this receiver fell behind the bounded live channel. The
|
||||
/// caller should recover through [`ExecProcess::read`] using the last
|
||||
/// delivered sequence number, then continue receiving pushed events.
|
||||
pub async fn recv(&mut self) -> Result<ExecProcessEvent, broadcast::error::RecvError> {
|
||||
if let Some(event) = self.replay.pop_front() {
|
||||
return Ok(event);
|
||||
}
|
||||
|
||||
self.live_rx.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for an executor-managed process.
|
||||
///
|
||||
/// Implementations must support both retained-output reads and pushed events:
|
||||
/// `read` is the request/response API for callers that want to page through
|
||||
/// buffered output, while `subscribe_events` is the streaming API for callers
|
||||
/// that want output and lifecycle changes delivered as they happen.
|
||||
#[async_trait]
|
||||
pub trait ExecProcess: Send + Sync {
|
||||
fn process_id(&self) -> &ProcessId;
|
||||
|
||||
fn subscribe_wake(&self) -> watch::Receiver<u64>;
|
||||
|
||||
fn subscribe_events(&self) -> ExecProcessEventReceiver;
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
after_seq: Option<u64>,
|
||||
@@ -35,3 +185,54 @@ pub trait ExecProcess: Send + Sync {
|
||||
pub trait ExecBackend: Send + Sync {
|
||||
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::ExecProcessEvent;
|
||||
use super::ExecProcessEventLog;
|
||||
use crate::protocol::ExecOutputStream;
|
||||
use crate::protocol::ProcessOutputChunk;
|
||||
|
||||
#[tokio::test]
|
||||
async fn event_history_replay_is_bounded_by_retained_bytes() {
|
||||
let log = ExecProcessEventLog::new(/*event_capacity*/ 8, /*byte_capacity*/ 3);
|
||||
|
||||
log.publish(ExecProcessEvent::Output(ProcessOutputChunk {
|
||||
seq: 1,
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: b"large".to_vec().into(),
|
||||
}));
|
||||
log.publish(ExecProcessEvent::Exited {
|
||||
seq: 2,
|
||||
exit_code: 0,
|
||||
});
|
||||
log.publish(ExecProcessEvent::Closed { seq: 3 });
|
||||
|
||||
let mut events = log.subscribe();
|
||||
let replay = vec![
|
||||
timeout(Duration::from_secs(1), events.recv())
|
||||
.await
|
||||
.expect("exit event replay should not time out")
|
||||
.expect("exit event replay should be available"),
|
||||
timeout(Duration::from_secs(1), events.recv())
|
||||
.await
|
||||
.expect("closed event replay should not time out")
|
||||
.expect("closed event replay should be available"),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
replay,
|
||||
vec![
|
||||
ExecProcessEvent::Exited {
|
||||
seq: 2,
|
||||
exit_code: 0,
|
||||
},
|
||||
ExecProcessEvent::Closed { seq: 3 },
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use tracing::trace;
|
||||
|
||||
use crate::ExecBackend;
|
||||
use crate::ExecProcess;
|
||||
use crate::ExecProcessEventReceiver;
|
||||
use crate::ExecServerError;
|
||||
use crate::StartedExecProcess;
|
||||
use crate::client::ExecServerClient;
|
||||
@@ -56,6 +57,10 @@ impl ExecProcess for RemoteExecProcess {
|
||||
self.session.subscribe_wake()
|
||||
}
|
||||
|
||||
fn subscribe_events(&self) -> ExecProcessEventReceiver {
|
||||
self.session.subscribe_events()
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
after_seq: Option<u64>,
|
||||
|
||||
@@ -57,6 +57,15 @@ pub(crate) fn test_codex_helper_paths() -> anyhow::Result<TestCodexHelperPaths>
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_server() -> anyhow::Result<ExecServerHarness> {
|
||||
exec_server_with_env(std::iter::empty::<(&str, &str)>()).await
|
||||
}
|
||||
|
||||
pub(crate) async fn exec_server_with_env<I, K, V>(env: I) -> anyhow::Result<ExecServerHarness>
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: AsRef<std::ffi::OsStr>,
|
||||
V: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let helper_paths = test_codex_helper_paths()?;
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut child = Command::new(&helper_paths.codex_exe);
|
||||
@@ -66,6 +75,7 @@ pub(crate) async fn exec_server() -> anyhow::Result<ExecServerHarness> {
|
||||
child.stderr(Stdio::inherit());
|
||||
child.kill_on_drop(true);
|
||||
child.env("CODEX_HOME", codex_home.path());
|
||||
child.envs(env);
|
||||
let mut child = child.spawn()?;
|
||||
|
||||
let websocket_url = read_listen_url_from_stdout(&mut child).await?;
|
||||
|
||||
@@ -7,8 +7,10 @@ use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecBackend;
|
||||
use codex_exec_server::ExecOutputStream;
|
||||
use codex_exec_server::ExecParams;
|
||||
use codex_exec_server::ExecProcess;
|
||||
use codex_exec_server::ExecProcessEvent;
|
||||
use codex_exec_server::ProcessId;
|
||||
use codex_exec_server::ReadResponse;
|
||||
use codex_exec_server::StartedExecProcess;
|
||||
@@ -27,6 +29,22 @@ struct ProcessContext {
|
||||
server: Option<ExecServerHarness>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ProcessEventSnapshot {
|
||||
Output {
|
||||
seq: u64,
|
||||
stream: ExecOutputStream,
|
||||
text: String,
|
||||
},
|
||||
Exited {
|
||||
seq: u64,
|
||||
exit_code: i32,
|
||||
},
|
||||
Closed {
|
||||
seq: u64,
|
||||
},
|
||||
}
|
||||
|
||||
async fn create_process_context(use_remote: bool) -> Result<ProcessContext> {
|
||||
if use_remote {
|
||||
let server = exec_server().await?;
|
||||
@@ -117,6 +135,69 @@ async fn collect_process_output_from_reads(
|
||||
Ok((output, exit_code, true))
|
||||
}
|
||||
|
||||
async fn collect_process_output_from_events(
|
||||
session: Arc<dyn ExecProcess>,
|
||||
) -> Result<(String, String, Option<i32>, bool)> {
|
||||
let mut events = session.subscribe_events();
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
let mut exit_code = None;
|
||||
loop {
|
||||
match timeout(Duration::from_secs(2), events.recv()).await?? {
|
||||
ExecProcessEvent::Output(chunk) => match chunk.stream {
|
||||
ExecOutputStream::Stdout | ExecOutputStream::Pty => {
|
||||
stdout.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
||||
}
|
||||
ExecOutputStream::Stderr => {
|
||||
stderr.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
||||
}
|
||||
},
|
||||
ExecProcessEvent::Exited {
|
||||
seq: _,
|
||||
exit_code: code,
|
||||
} => {
|
||||
exit_code = Some(code);
|
||||
}
|
||||
ExecProcessEvent::Closed { seq: _ } => {
|
||||
drop(session);
|
||||
return Ok((stdout, stderr, exit_code, true));
|
||||
}
|
||||
ExecProcessEvent::Failed(message) => {
|
||||
anyhow::bail!("process failed before closed state: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_process_event_snapshots(
|
||||
session: Arc<dyn ExecProcess>,
|
||||
) -> Result<Vec<ProcessEventSnapshot>> {
|
||||
let mut events = session.subscribe_events();
|
||||
let mut snapshots = Vec::new();
|
||||
loop {
|
||||
let snapshot = match timeout(Duration::from_secs(2), events.recv()).await?? {
|
||||
ExecProcessEvent::Output(chunk) => ProcessEventSnapshot::Output {
|
||||
seq: chunk.seq,
|
||||
stream: chunk.stream,
|
||||
text: String::from_utf8_lossy(&chunk.chunk.into_inner()).into_owned(),
|
||||
},
|
||||
ExecProcessEvent::Exited { seq, exit_code } => {
|
||||
ProcessEventSnapshot::Exited { seq, exit_code }
|
||||
}
|
||||
ExecProcessEvent::Closed { seq } => ProcessEventSnapshot::Closed { seq },
|
||||
ExecProcessEvent::Failed(message) => {
|
||||
anyhow::bail!("process failed before closed state: {message}");
|
||||
}
|
||||
};
|
||||
let closed = matches!(snapshot, ProcessEventSnapshot::Closed { .. });
|
||||
snapshots.push(snapshot);
|
||||
if closed {
|
||||
drop(session);
|
||||
return Ok(snapshots);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
|
||||
let context = create_process_context(use_remote).await?;
|
||||
let process_id = "proc-stream".to_string();
|
||||
@@ -148,6 +229,96 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> {
|
||||
let context = create_process_context(use_remote).await?;
|
||||
let process_id = "proc-events".to_string();
|
||||
let session = context
|
||||
.backend
|
||||
.start(ExecParams {
|
||||
process_id: process_id.clone().into(),
|
||||
argv: vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"printf 'event output\\n'; sleep 0.1; printf 'event err\\n' >&2; sleep 0.1; exit 7".to_string(),
|
||||
],
|
||||
cwd: std::env::current_dir()?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
arg0: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
|
||||
let StartedExecProcess { process } = session;
|
||||
let actual = collect_process_event_snapshots(process).await?;
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![
|
||||
ProcessEventSnapshot::Output {
|
||||
seq: 1,
|
||||
stream: ExecOutputStream::Stdout,
|
||||
text: "event output\n".to_string(),
|
||||
},
|
||||
ProcessEventSnapshot::Output {
|
||||
seq: 2,
|
||||
stream: ExecOutputStream::Stderr,
|
||||
text: "event err\n".to_string(),
|
||||
},
|
||||
ProcessEventSnapshot::Exited {
|
||||
seq: 3,
|
||||
exit_code: 7,
|
||||
},
|
||||
ProcessEventSnapshot::Closed { seq: 4 },
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Result<()> {
|
||||
let context = create_process_context(use_remote).await?;
|
||||
let process_id = "proc-events-late".to_string();
|
||||
let session = context
|
||||
.backend
|
||||
.start(ExecParams {
|
||||
process_id: process_id.clone().into(),
|
||||
argv: vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"printf 'late one\\n'; printf 'late two\\n'".to_string(),
|
||||
],
|
||||
cwd: std::env::current_dir()?,
|
||||
env_policy: /*env_policy*/ None,
|
||||
env: Default::default(),
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
arg0: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(session.process.process_id().as_str(), process_id);
|
||||
|
||||
let StartedExecProcess { process } = session;
|
||||
let wake_rx = process.subscribe_wake();
|
||||
let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?;
|
||||
assert_eq!(
|
||||
read_result,
|
||||
("late one\nlate two\n".to_string(), Some(0), true)
|
||||
);
|
||||
|
||||
let event_result = collect_process_output_from_events(process).await?;
|
||||
assert_eq!(
|
||||
event_result,
|
||||
(
|
||||
"late one\nlate two\n".to_string(),
|
||||
String::new(),
|
||||
Some(0),
|
||||
true
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
|
||||
let context = create_process_context(use_remote).await?;
|
||||
let process_id = "proc-stdin".to_string();
|
||||
@@ -311,15 +482,25 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let process = Arc::clone(&session.process);
|
||||
let mut events = process.subscribe_events();
|
||||
let server = context
|
||||
.server
|
||||
.as_mut()
|
||||
.expect("remote context should include exec-server harness");
|
||||
server.shutdown().await?;
|
||||
|
||||
let mut wake_rx = session.process.subscribe_wake();
|
||||
let response =
|
||||
read_process_until_change(session.process, &mut wake_rx, /*after_seq*/ None).await?;
|
||||
let event = timeout(Duration::from_secs(2), events.recv()).await??;
|
||||
let ExecProcessEvent::Failed(event_message) = event else {
|
||||
anyhow::bail!("expected process failure event, got {event:?}");
|
||||
};
|
||||
assert!(
|
||||
event_message.starts_with("exec-server transport disconnected"),
|
||||
"unexpected failure event: {event_message}"
|
||||
);
|
||||
|
||||
let mut wake_rx = process.subscribe_wake();
|
||||
let response = read_process_until_change(process, &mut wake_rx, /*after_seq*/ None).await?;
|
||||
let message = response
|
||||
.failure
|
||||
.expect("disconnect should surface as a failure");
|
||||
@@ -353,6 +534,24 @@ async fn exec_process_streams_output(use_remote: bool) -> Result<()> {
|
||||
assert_exec_process_streams_output(use_remote).await
|
||||
}
|
||||
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
// Serialize tests that launch a real exec-server process through the full CLI.
|
||||
#[serial_test::serial(remote_exec_server)]
|
||||
async fn exec_process_pushes_events(use_remote: bool) -> Result<()> {
|
||||
assert_exec_process_pushes_events(use_remote).await
|
||||
}
|
||||
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
// Serialize tests that launch a real exec-server process through the full CLI.
|
||||
#[serial_test::serial(remote_exec_server)]
|
||||
async fn exec_process_replays_events_after_close(use_remote: bool) -> Result<()> {
|
||||
assert_exec_process_replays_events_after_close(use_remote).await
|
||||
}
|
||||
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
mod common;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -31,6 +33,8 @@ use test_case::test_case;
|
||||
use common::exec_server::ExecServerHarness;
|
||||
use common::exec_server::TestCodexHelperPaths;
|
||||
use common::exec_server::exec_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
use common::exec_server::exec_server_with_env;
|
||||
use common::exec_server::test_codex_helper_paths;
|
||||
|
||||
struct FileSystemContext {
|
||||
@@ -148,6 +152,96 @@ fn alias_root_candidate() -> Result<Option<PathBuf>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn write_fake_bwrap(bin_dir: &Path) -> Result<PathBuf> {
|
||||
std::fs::create_dir_all(bin_dir)?;
|
||||
let fake_bwrap = bin_dir.join("bwrap");
|
||||
std::fs::write(
|
||||
&fake_bwrap,
|
||||
r#"#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "${arg}" == "--help" ]]; then
|
||||
echo "Usage: bwrap --argv0"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s\n' "$*" >> "${0}.log"
|
||||
|
||||
args=("$@")
|
||||
argv0=""
|
||||
command_start=-1
|
||||
for i in "${!args[@]}"; do
|
||||
if [[ "${args[$i]}" == "--argv0" && $((i + 1)) -lt ${#args[@]} ]]; then
|
||||
argv0="${args[$((i + 1))]}"
|
||||
fi
|
||||
if [[ "${args[$i]}" == "--" ]]; then
|
||||
command_start=$((i + 1))
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${command_start}" -lt 0 || "${command_start}" -ge "${#args[@]}" ]]; then
|
||||
echo "fake bwrap did not find an inner command" >&2
|
||||
exit 125
|
||||
fi
|
||||
|
||||
cmd=("${args[@]:$command_start}")
|
||||
if [[ -n "${argv0}" ]]; then
|
||||
exec -a "${argv0}" "${cmd[@]}"
|
||||
fi
|
||||
exec "${cmd[@]}"
|
||||
"#,
|
||||
)?;
|
||||
let mut permissions = std::fs::metadata(&fake_bwrap)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake_bwrap, permissions)?;
|
||||
Ok(fake_bwrap)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let fake_bin_dir = tmp.path().join("bin");
|
||||
let fake_bwrap = write_fake_bwrap(&fake_bin_dir)?;
|
||||
let mut path_entries = vec![fake_bin_dir];
|
||||
if let Some(path) = std::env::var_os("PATH") {
|
||||
path_entries.extend(std::env::split_paths(&path));
|
||||
}
|
||||
let helper_path = std::env::join_paths(path_entries)?;
|
||||
|
||||
let server = exec_server_with_env([("PATH", helper_path.as_os_str())]).await?;
|
||||
let environment = Environment::create(Some(server.websocket_url().to_string())).await?;
|
||||
let file_system = environment.get_filesystem();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
let file_path = workspace.join("created.txt");
|
||||
let sandbox = workspace_write_sandbox(workspace);
|
||||
|
||||
file_system
|
||||
.write_file(
|
||||
&absolute_path(file_path.clone()),
|
||||
b"written through fs helper".to_vec(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(std::fs::read(&file_path)?, b"written through fs helper");
|
||||
|
||||
let bwrap_log = fake_bwrap.with_file_name("bwrap.log");
|
||||
let log = std::fs::read_to_string(&bwrap_log)
|
||||
.with_context(|| format!("expected fake bwrap log at {}", bwrap_log.display()))?;
|
||||
assert!(
|
||||
log.contains("--argv0"),
|
||||
"expected fs helper sandbox path to invoke PATH bwrap with --argv0, got: {log}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(false ; "local")]
|
||||
#[test_case(true ; "remote")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -1234,6 +1234,7 @@ async fn resolve_resume_thread_id(
|
||||
cursor,
|
||||
limit: Some(100),
|
||||
sort_key: Some(ThreadSortKey::UpdatedAt),
|
||||
sort_direction: None,
|
||||
model_providers: model_providers.clone(),
|
||||
source_kinds: Some(all_thread_source_kinds()),
|
||||
archived: Some(false),
|
||||
@@ -1296,6 +1297,7 @@ async fn resolve_resume_thread_id(
|
||||
cursor,
|
||||
limit: Some(100),
|
||||
sort_key: Some(ThreadSortKey::UpdatedAt),
|
||||
sort_direction: None,
|
||||
model_providers: model_providers.clone(),
|
||||
source_kinds: Some(all_thread_source_kinds()),
|
||||
archived: Some(false),
|
||||
|
||||
@@ -36,6 +36,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
|
||||
}),
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2230,6 +2230,18 @@ pub struct RateLimitSnapshot {
|
||||
pub secondary: Option<RateLimitWindow>,
|
||||
pub credits: Option<CreditsSnapshot>,
|
||||
pub plan_type: Option<crate::account::PlanType>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum RateLimitReachedType {
|
||||
RateLimitReached,
|
||||
WorkspaceOwnerCreditsDepleted,
|
||||
WorkspaceMemberCreditsDepleted,
|
||||
WorkspaceOwnerUsageLimitReached,
|
||||
WorkspaceMemberUsageLimitReached,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
|
||||
@@ -418,22 +418,23 @@ async fn fail_session_post_when_armed(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let mut armed_failure = state.armed_failure.lock().await;
|
||||
if let Some(failure) = armed_failure.as_mut()
|
||||
&& failure.remaining > 0
|
||||
{
|
||||
failure.remaining -= 1;
|
||||
let status = failure.status;
|
||||
if failure.remaining == 0 {
|
||||
*armed_failure = None;
|
||||
let mut armed_failure = state.armed_failure.lock().await;
|
||||
if let Some(failure) = armed_failure.as_mut()
|
||||
&& failure.remaining > 0
|
||||
{
|
||||
failure.remaining -= 1;
|
||||
let status = failure.status;
|
||||
if failure.remaining == 0 {
|
||||
*armed_failure = None;
|
||||
}
|
||||
let mut response = Response::new(Body::from(format!(
|
||||
"forced session failure with status {status}"
|
||||
)));
|
||||
*response.status_mut() = status;
|
||||
return response;
|
||||
}
|
||||
let mut response = Response::new(Body::from(format!(
|
||||
"forced session failure with status {status}"
|
||||
)));
|
||||
*response.status_mut() = status;
|
||||
return response;
|
||||
}
|
||||
|
||||
drop(armed_failure);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod oauth;
|
||||
mod perform_oauth_login;
|
||||
mod program_resolver;
|
||||
mod rmcp_client;
|
||||
mod stdio_server_launcher;
|
||||
mod utils;
|
||||
|
||||
pub use auth_status::StreamableHttpOAuthDiscovery;
|
||||
@@ -29,3 +30,5 @@ pub use rmcp_client::ListToolsWithConnectorIdResult;
|
||||
pub use rmcp_client::RmcpClient;
|
||||
pub use rmcp_client::SendElicitation;
|
||||
pub use rmcp_client::ToolWithConnectorId;
|
||||
pub use stdio_server_launcher::LocalStdioServerLauncher;
|
||||
pub use stdio_server_launcher::StdioServerLauncher;
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -52,7 +51,6 @@ use rmcp::transport::StreamableHttpClientTransport;
|
||||
use rmcp::transport::auth::AuthClient;
|
||||
use rmcp::transport::auth::AuthError;
|
||||
use rmcp::transport::auth::OAuthState;
|
||||
use rmcp::transport::child_process::TokioChildProcess;
|
||||
use rmcp::transport::streamable_http_client::AuthRequiredError;
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpClient;
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||
@@ -63,23 +61,20 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use sse_stream::Sse;
|
||||
use sse_stream::SseStream;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::elicitation_client_service::ElicitationClientService;
|
||||
use crate::load_oauth_tokens;
|
||||
use crate::oauth::OAuthPersistor;
|
||||
use crate::oauth::StoredOAuthTokens;
|
||||
use crate::program_resolver;
|
||||
use crate::stdio_server_launcher::StdioServerCommand;
|
||||
use crate::stdio_server_launcher::StdioServerLauncher;
|
||||
use crate::stdio_server_launcher::StdioServerTransport;
|
||||
use crate::utils::apply_default_headers;
|
||||
use crate::utils::build_default_headers;
|
||||
use crate::utils::create_env_for_mcp_server;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
|
||||
const EVENT_STREAM_MIME_TYPE: &str = "text/event-stream";
|
||||
@@ -307,9 +302,8 @@ impl StreamableHttpClient for StreamableHttpResponseClient {
|
||||
}
|
||||
|
||||
enum PendingTransport {
|
||||
ChildProcess {
|
||||
transport: TokioChildProcess,
|
||||
process_group_guard: Option<ProcessGroupGuard>,
|
||||
Stdio {
|
||||
transport: StdioServerTransport,
|
||||
},
|
||||
StreamableHttp {
|
||||
transport: StreamableHttpClientTransport<StreamableHttpResponseClient>,
|
||||
@@ -325,79 +319,16 @@ enum ClientState {
|
||||
transport: Option<PendingTransport>,
|
||||
},
|
||||
Ready {
|
||||
_process_group_guard: Option<ProcessGroupGuard>,
|
||||
service: Arc<RunningService<RoleClient, ElicitationClientService>>,
|
||||
oauth: Option<OAuthPersistor>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const PROCESS_GROUP_TERM_GRACE_PERIOD: Duration = Duration::from_secs(2);
|
||||
|
||||
#[cfg(unix)]
|
||||
struct ProcessGroupGuard {
|
||||
process_group_id: u32,
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
struct ProcessGroupGuard;
|
||||
|
||||
impl ProcessGroupGuard {
|
||||
fn new(process_group_id: u32) -> Self {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Self { process_group_id }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = process_group_id;
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn maybe_terminate_process_group(&self) {
|
||||
let process_group_id = self.process_group_id;
|
||||
let should_escalate =
|
||||
match codex_utils_pty::process_group::terminate_process_group(process_group_id) {
|
||||
Ok(exists) => exists,
|
||||
Err(error) => {
|
||||
warn!("Failed to terminate MCP process group {process_group_id}: {error}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_escalate {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(PROCESS_GROUP_TERM_GRACE_PERIOD);
|
||||
if let Err(error) =
|
||||
codex_utils_pty::process_group::kill_process_group(process_group_id)
|
||||
{
|
||||
warn!("Failed to kill MCP process group {process_group_id}: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn maybe_terminate_process_group(&self) {}
|
||||
}
|
||||
|
||||
impl Drop for ProcessGroupGuard {
|
||||
fn drop(&mut self) {
|
||||
if cfg!(unix) {
|
||||
self.maybe_terminate_process_group();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum TransportRecipe {
|
||||
Stdio {
|
||||
program: OsString,
|
||||
args: Vec<OsString>,
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
command: StdioServerCommand,
|
||||
launcher: Arc<dyn StdioServerLauncher>,
|
||||
},
|
||||
StreamableHttp {
|
||||
server_name: String,
|
||||
@@ -574,13 +505,11 @@ impl RmcpClient {
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: &[String],
|
||||
cwd: Option<PathBuf>,
|
||||
launcher: Arc<dyn StdioServerLauncher>,
|
||||
) -> io::Result<Self> {
|
||||
let transport_recipe = TransportRecipe::Stdio {
|
||||
program,
|
||||
args,
|
||||
env,
|
||||
env_vars: env_vars.to_vec(),
|
||||
cwd,
|
||||
command: StdioServerCommand::new(program, args, env, env_vars.to_vec(), cwd),
|
||||
launcher,
|
||||
};
|
||||
let transport = Self::create_pending_transport(&transport_recipe)
|
||||
.await
|
||||
@@ -650,7 +579,7 @@ impl RmcpClient {
|
||||
}
|
||||
};
|
||||
|
||||
let (service, oauth_persistor, process_group_guard) =
|
||||
let (service, oauth_persistor) =
|
||||
Self::connect_pending_transport(pending_transport, client_service.clone(), timeout)
|
||||
.await?;
|
||||
|
||||
@@ -671,7 +600,6 @@ impl RmcpClient {
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
*guard = ClientState::Ready {
|
||||
_process_group_guard: process_group_guard,
|
||||
service,
|
||||
oauth: oauth_persistor.clone(),
|
||||
};
|
||||
@@ -954,60 +882,9 @@ impl RmcpClient {
|
||||
transport_recipe: &TransportRecipe,
|
||||
) -> Result<PendingTransport> {
|
||||
match transport_recipe {
|
||||
TransportRecipe::Stdio {
|
||||
program,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => {
|
||||
let program_name = program.to_string_lossy().into_owned();
|
||||
let envs = create_env_for_mcp_server(env.clone(), env_vars);
|
||||
let resolved_program = program_resolver::resolve(program.clone(), &envs)?;
|
||||
|
||||
let mut command = Command::new(resolved_program);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.env_clear()
|
||||
.envs(envs)
|
||||
.args(args);
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
|
||||
let (transport, stderr) = TokioChildProcess::builder(command)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let process_group_guard = transport.id().map(ProcessGroupGuard::new);
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
loop {
|
||||
match reader.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
info!("MCP server stderr ({program_name}): {line}");
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
"Failed to read MCP server stderr ({program_name}): {error}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PendingTransport::ChildProcess {
|
||||
transport,
|
||||
process_group_guard,
|
||||
})
|
||||
TransportRecipe::Stdio { command, launcher } => {
|
||||
let transport = launcher.launch(command.clone()).await?;
|
||||
Ok(PendingTransport::Stdio { transport })
|
||||
}
|
||||
TransportRecipe::StreamableHttp {
|
||||
server_name,
|
||||
@@ -1101,21 +978,15 @@ impl RmcpClient {
|
||||
) -> Result<(
|
||||
Arc<RunningService<RoleClient, ElicitationClientService>>,
|
||||
Option<OAuthPersistor>,
|
||||
Option<ProcessGroupGuard>,
|
||||
)> {
|
||||
let (transport, oauth_persistor, process_group_guard) = match pending_transport {
|
||||
PendingTransport::ChildProcess {
|
||||
transport,
|
||||
process_group_guard,
|
||||
} => (
|
||||
let (transport, oauth_persistor) = match pending_transport {
|
||||
PendingTransport::Stdio { transport } => (
|
||||
service::serve_client(client_service, transport).boxed(),
|
||||
None,
|
||||
process_group_guard,
|
||||
),
|
||||
PendingTransport::StreamableHttp { transport } => (
|
||||
service::serve_client(client_service, transport).boxed(),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
PendingTransport::StreamableHttpWithOAuth {
|
||||
transport,
|
||||
@@ -1123,7 +994,6 @@ impl RmcpClient {
|
||||
} => (
|
||||
service::serve_client(client_service, transport).boxed(),
|
||||
Some(oauth_persistor),
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1137,7 +1007,7 @@ impl RmcpClient {
|
||||
.map_err(|err| anyhow!("handshaking with MCP server failed: {err}"))?,
|
||||
};
|
||||
|
||||
Ok((Arc::new(service), oauth_persistor, process_group_guard))
|
||||
Ok((Arc::new(service), oauth_persistor))
|
||||
}
|
||||
|
||||
async fn run_service_operation<T, F, Fut>(
|
||||
@@ -1249,7 +1119,7 @@ impl RmcpClient {
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("MCP client cannot recover before initialize succeeds"))?;
|
||||
let pending_transport = Self::create_pending_transport(&self.transport_recipe).await?;
|
||||
let (service, oauth_persistor, process_group_guard) = Self::connect_pending_transport(
|
||||
let (service, oauth_persistor) = Self::connect_pending_transport(
|
||||
pending_transport,
|
||||
initialize_context.client_service,
|
||||
initialize_context.timeout,
|
||||
@@ -1259,7 +1129,6 @@ impl RmcpClient {
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
*guard = ClientState::Ready {
|
||||
_process_group_guard: process_group_guard,
|
||||
service,
|
||||
oauth: oauth_persistor.clone(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Launch MCP stdio servers and return the transport rmcp should use.
|
||||
//!
|
||||
//! This module owns the "where does the server process run?" boundary for
|
||||
//! stdio MCP servers. In this PR there is only the local launcher, which keeps
|
||||
//! the existing behavior: the orchestrator starts the configured command and
|
||||
//! rmcp talks to the child process through local stdin/stdout pipes.
|
||||
//!
|
||||
//! Later stack entries add an executor-backed launcher without changing
|
||||
//! `RmcpClient`'s MCP lifecycle code.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
#[cfg(unix)]
|
||||
use std::thread::sleep;
|
||||
#[cfg(unix)]
|
||||
use std::thread::spawn;
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
use codex_utils_pty::process_group::kill_process_group;
|
||||
#[cfg(unix)]
|
||||
use codex_utils_pty::process_group::terminate_process_group;
|
||||
use futures::FutureExt;
|
||||
use futures::future::BoxFuture;
|
||||
use rmcp::service::RoleClient;
|
||||
use rmcp::service::RxJsonRpcMessage;
|
||||
use rmcp::service::TxJsonRpcMessage;
|
||||
use rmcp::transport::Transport;
|
||||
use rmcp::transport::child_process::TokioChildProcess;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Command;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::program_resolver;
|
||||
use crate::utils::create_env_for_mcp_server;
|
||||
|
||||
// General purpose public code.
|
||||
|
||||
/// Launches an MCP stdio server and returns the transport for rmcp.
|
||||
///
|
||||
/// This trait is the boundary between MCP lifecycle code and process placement.
|
||||
/// `RmcpClient` owns MCP operations such as `initialize` and `tools/list`; the
|
||||
/// launcher owns starting the configured command and producing an rmcp
|
||||
/// [`Transport`] over the server's stdin/stdout bytes.
|
||||
pub trait StdioServerLauncher: private::Sealed + Send + Sync {
|
||||
/// Start the configured stdio server and return its rmcp-facing transport.
|
||||
fn launch(
|
||||
&self,
|
||||
command: StdioServerCommand,
|
||||
) -> BoxFuture<'static, io::Result<StdioServerTransport>>;
|
||||
}
|
||||
|
||||
/// Command-line process shape shared by stdio server launchers.
|
||||
#[derive(Clone)]
|
||||
pub struct StdioServerCommand {
|
||||
program: OsString,
|
||||
args: Vec<OsString>,
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Client-side rmcp transport for a launched MCP stdio server.
|
||||
///
|
||||
/// The concrete process placement stays private to this module. `RmcpClient`
|
||||
/// only sees the standard rmcp transport abstraction and can pass this value
|
||||
/// directly to `rmcp::service::serve_client`.
|
||||
pub struct StdioServerTransport {
|
||||
inner: StdioServerTransportInner,
|
||||
_process_group_guard: Option<ProcessGroupGuard>,
|
||||
}
|
||||
|
||||
enum StdioServerTransportInner {
|
||||
Local(TokioChildProcess),
|
||||
}
|
||||
|
||||
impl Transport<RoleClient> for StdioServerTransport {
|
||||
type Error = io::Error;
|
||||
|
||||
fn send(
|
||||
&mut self,
|
||||
item: TxJsonRpcMessage<RoleClient>,
|
||||
) -> impl Future<Output = std::result::Result<(), Self::Error>> + Send + 'static {
|
||||
match &mut self.inner {
|
||||
StdioServerTransportInner::Local(transport) => transport.send(item).boxed(),
|
||||
}
|
||||
}
|
||||
|
||||
fn receive(&mut self) -> impl Future<Output = Option<RxJsonRpcMessage<RoleClient>>> + Send {
|
||||
match &mut self.inner {
|
||||
StdioServerTransportInner::Local(transport) => transport.receive().boxed(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> std::result::Result<(), Self::Error> {
|
||||
match &mut self.inner {
|
||||
StdioServerTransportInner::Local(transport) => transport.close().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StdioServerCommand {
|
||||
/// Build the stdio process parameters before choosing where the process
|
||||
/// runs.
|
||||
pub(super) fn new(
|
||||
program: OsString,
|
||||
args: Vec<OsString>,
|
||||
env: Option<HashMap<OsString, OsString>>,
|
||||
env_vars: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
program,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local public implementation.
|
||||
|
||||
/// Starts MCP stdio servers as local child processes.
|
||||
///
|
||||
/// This is the existing behavior for local MCP servers: the orchestrator
|
||||
/// process spawns the configured command and rmcp talks to the child's local
|
||||
/// stdin/stdout pipes directly.
|
||||
#[derive(Clone)]
|
||||
pub struct LocalStdioServerLauncher;
|
||||
|
||||
impl StdioServerLauncher for LocalStdioServerLauncher {
|
||||
fn launch(
|
||||
&self,
|
||||
command: StdioServerCommand,
|
||||
) -> BoxFuture<'static, io::Result<StdioServerTransport>> {
|
||||
async move { Self::launch_server(command) }.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
// Local private implementation.
|
||||
|
||||
#[cfg(unix)]
|
||||
const PROCESS_GROUP_TERM_GRACE_PERIOD: Duration = Duration::from_secs(2);
|
||||
|
||||
#[cfg(unix)]
|
||||
struct ProcessGroupGuard {
|
||||
process_group_id: u32,
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
struct ProcessGroupGuard;
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
impl private::Sealed for LocalStdioServerLauncher {}
|
||||
|
||||
impl LocalStdioServerLauncher {
|
||||
fn launch_server(command: StdioServerCommand) -> io::Result<StdioServerTransport> {
|
||||
let StdioServerCommand {
|
||||
program,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} = command;
|
||||
let program_name = program.to_string_lossy().into_owned();
|
||||
let envs = create_env_for_mcp_server(env, &env_vars);
|
||||
let resolved_program =
|
||||
program_resolver::resolve(program, &envs).map_err(io::Error::other)?;
|
||||
|
||||
let mut command = Command::new(resolved_program);
|
||||
command
|
||||
.kill_on_drop(true)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.env_clear()
|
||||
.envs(envs)
|
||||
.args(args);
|
||||
#[cfg(unix)]
|
||||
command.process_group(0);
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
|
||||
let (transport, stderr) = TokioChildProcess::builder(command)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let process_group_guard = transport.id().map(ProcessGroupGuard::new);
|
||||
|
||||
if let Some(stderr) = stderr {
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
loop {
|
||||
match reader.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
info!("MCP server stderr ({program_name}): {line}");
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
warn!("Failed to read MCP server stderr ({program_name}): {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(StdioServerTransport {
|
||||
inner: StdioServerTransportInner::Local(transport),
|
||||
_process_group_guard: process_group_guard,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessGroupGuard {
|
||||
fn new(process_group_id: u32) -> Self {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Self { process_group_id }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = process_group_id;
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn maybe_terminate_process_group(&self) {
|
||||
let process_group_id = self.process_group_id;
|
||||
let should_escalate = match terminate_process_group(process_group_id) {
|
||||
Ok(exists) => exists,
|
||||
Err(error) => {
|
||||
warn!("Failed to terminate MCP process group {process_group_id}: {error}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if should_escalate {
|
||||
spawn(move || {
|
||||
sleep(PROCESS_GROUP_TERM_GRACE_PERIOD);
|
||||
if let Err(error) = kill_process_group(process_group_id) {
|
||||
warn!("Failed to kill MCP process group {process_group_id}: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn maybe_terminate_process_group(&self) {}
|
||||
}
|
||||
|
||||
impl Drop for ProcessGroupGuard {
|
||||
fn drop(&mut self) {
|
||||
if cfg!(unix) {
|
||||
self.maybe_terminate_process_group();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_rmcp_client::LocalStdioServerLauncher;
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
|
||||
fn process_exists(pid: u32) -> bool {
|
||||
@@ -78,6 +80,7 @@ async fn drop_kills_wrapper_process_group() -> Result<()> {
|
||||
)])),
|
||||
&[],
|
||||
/*cwd*/ None,
|
||||
Arc::new(LocalStdioServerLauncher),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::LocalStdioServerLauncher;
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
use codex_utils_cargo_bin::CargoBinError;
|
||||
use futures::FutureExt as _;
|
||||
@@ -61,6 +63,7 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> {
|
||||
/*env*/ None,
|
||||
&[],
|
||||
/*cwd*/ None,
|
||||
Arc::new(LocalStdioServerLauncher),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ pub use config::Config;
|
||||
pub use config::RolloutConfig;
|
||||
pub use config::RolloutConfigView;
|
||||
pub use list::Cursor;
|
||||
pub use list::SortDirection;
|
||||
pub use list::ThreadItem;
|
||||
pub use list::ThreadListConfig;
|
||||
pub use list::ThreadListLayout;
|
||||
|
||||
@@ -111,6 +111,12 @@ pub enum ThreadSortKey {
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ThreadListLayout {
|
||||
NestedByDate,
|
||||
@@ -124,26 +130,28 @@ pub struct ThreadListConfig<'a> {
|
||||
pub layout: ThreadListLayout,
|
||||
}
|
||||
|
||||
/// Pagination cursor identifying a file by timestamp and UUID.
|
||||
/// Pagination cursor identifying the timestamp of the last item in a page.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Cursor {
|
||||
ts: OffsetDateTime,
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
fn new(ts: OffsetDateTime, id: Uuid) -> Self {
|
||||
Self { ts, id }
|
||||
fn new(ts: OffsetDateTime) -> Self {
|
||||
Self { ts }
|
||||
}
|
||||
|
||||
pub(crate) fn timestamp(&self) -> OffsetDateTime {
|
||||
self.ts
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest,
|
||||
/// it ignores everything until it reaches the last seen item from the previous page, then
|
||||
/// it ignores everything until it passes the last seen timestamp from the previous page, then
|
||||
/// starts returning results after that. This makes paging stable even if new files show up during
|
||||
/// pagination.
|
||||
struct AnchorState {
|
||||
ts: OffsetDateTime,
|
||||
id: Uuid,
|
||||
passed: bool,
|
||||
}
|
||||
|
||||
@@ -152,22 +160,20 @@ impl AnchorState {
|
||||
match anchor {
|
||||
Some(cursor) => Self {
|
||||
ts: cursor.ts,
|
||||
id: cursor.id,
|
||||
passed: false,
|
||||
},
|
||||
None => Self {
|
||||
ts: OffsetDateTime::UNIX_EPOCH,
|
||||
id: Uuid::nil(),
|
||||
passed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip(&mut self, ts: OffsetDateTime, id: Uuid) -> bool {
|
||||
fn should_skip(&mut self, ts: OffsetDateTime, _id: Uuid) -> bool {
|
||||
if self.passed {
|
||||
return false;
|
||||
}
|
||||
if ts < self.ts || (ts == self.ts && id < self.id) {
|
||||
if ts < self.ts {
|
||||
self.passed = true;
|
||||
false
|
||||
} else {
|
||||
@@ -275,7 +281,7 @@ impl serde::Serialize for Cursor {
|
||||
.ts
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?;
|
||||
serializer.serialize_str(&format!("{ts_str}|{}", self.id))
|
||||
serializer.serialize_str(&ts_str)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,14 +302,14 @@ impl From<codex_state::Anchor> for Cursor {
|
||||
.timestamp_nanos_opt()
|
||||
.and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok())
|
||||
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
Self::new(ts, anchor.id)
|
||||
Self::new(ts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve recorded thread file paths with token pagination. The returned `next_cursor`
|
||||
/// can be supplied on the next call to resume after the last returned item, resilient to
|
||||
/// concurrent new sessions being appended. Ordering is stable by the requested sort key
|
||||
/// (timestamp desc, then UUID desc).
|
||||
/// (timestamp desc).
|
||||
pub async fn get_threads(
|
||||
codex_home: &Path,
|
||||
page_size: usize,
|
||||
@@ -657,31 +663,27 @@ async fn traverse_flat_paths_updated(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pagination cursor token format: "<ts>|<uuid>" where `ts` uses
|
||||
/// YYYY-MM-DDThh-mm-ss (UTC, second precision).
|
||||
/// The cursor orders files by the requested sort key (timestamp desc, then UUID desc).
|
||||
/// Pagination cursor token format: an RFC3339 timestamp.
|
||||
pub fn parse_cursor(token: &str) -> Option<Cursor> {
|
||||
let (file_ts, uuid_str) = token.split_once('|')?;
|
||||
|
||||
let Ok(uuid) = Uuid::parse_str(uuid_str) else {
|
||||
if token.contains('|') {
|
||||
return None;
|
||||
};
|
||||
}
|
||||
|
||||
let ts = OffsetDateTime::parse(file_ts, &Rfc3339).ok().or_else(|| {
|
||||
let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| {
|
||||
let format: &[FormatItem] =
|
||||
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
|
||||
PrimitiveDateTime::parse(file_ts, format)
|
||||
PrimitiveDateTime::parse(token, format)
|
||||
.ok()
|
||||
.map(PrimitiveDateTime::assume_utc)
|
||||
})?;
|
||||
|
||||
Some(Cursor::new(ts, uuid))
|
||||
Some(Cursor::new(ts))
|
||||
}
|
||||
|
||||
fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cursor> {
|
||||
let last = items.last()?;
|
||||
let file_name = last.path.file_name()?.to_string_lossy();
|
||||
let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
|
||||
let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?;
|
||||
let ts = match sort_key {
|
||||
ThreadSortKey::CreatedAt => created_ts,
|
||||
ThreadSortKey::UpdatedAt => {
|
||||
@@ -689,7 +691,7 @@ fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cu
|
||||
OffsetDateTime::parse(updated_at, &Rfc3339).ok()?
|
||||
}
|
||||
};
|
||||
Some(Cursor::new(ts, id))
|
||||
Some(Cursor::new(ts))
|
||||
}
|
||||
|
||||
async fn build_thread_item(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::Error as IoError;
|
||||
@@ -32,6 +33,7 @@ use tracing::warn;
|
||||
use super::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use super::SESSIONS_SUBDIR;
|
||||
use super::list::Cursor;
|
||||
use super::list::SortDirection;
|
||||
use super::list::ThreadItem;
|
||||
use super::list::ThreadListConfig;
|
||||
use super::list::ThreadListLayout;
|
||||
@@ -44,6 +46,7 @@ use super::list::parse_timestamp_uuid_from_filename;
|
||||
use super::metadata;
|
||||
use super::policy::EventPersistenceMode;
|
||||
use super::policy::is_persisted_response_item;
|
||||
use super::session_index::find_thread_names_by_ids;
|
||||
use crate::config::RolloutConfigView;
|
||||
use crate::default_client::originator;
|
||||
use crate::state_db;
|
||||
@@ -219,6 +222,7 @@ impl RolloutRecorder {
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
sort_direction: SortDirection,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
default_provider: &str,
|
||||
@@ -229,6 +233,7 @@ impl RolloutRecorder {
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
sort_direction,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
@@ -245,6 +250,7 @@ impl RolloutRecorder {
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
sort_direction: SortDirection,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
default_provider: &str,
|
||||
@@ -255,6 +261,7 @@ impl RolloutRecorder {
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
sort_direction,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
@@ -270,6 +277,7 @@ impl RolloutRecorder {
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
sort_direction: SortDirection,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
default_provider: &str,
|
||||
@@ -290,6 +298,7 @@ impl RolloutRecorder {
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
sort_direction,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
archived,
|
||||
@@ -304,38 +313,44 @@ impl RolloutRecorder {
|
||||
// Filesystem-first listing intentionally overfetches so we can repair stale/missing
|
||||
// SQLite rollout paths before the final DB-backed page is returned.
|
||||
let fs_page_size = page_size.saturating_mul(2).max(page_size);
|
||||
let fs_page = if archived {
|
||||
let root = codex_home.join(ARCHIVED_SESSIONS_SUBDIR);
|
||||
get_threads_in_root(
|
||||
root,
|
||||
fs_page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
ThreadListConfig {
|
||||
let fs_page = match sort_direction {
|
||||
SortDirection::Asc => {
|
||||
list_threads_from_files_asc(
|
||||
codex_home,
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
layout: ThreadListLayout::Flat,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
get_threads(
|
||||
codex_home,
|
||||
fs_page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
)
|
||||
.await?
|
||||
archived,
|
||||
search_term,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
SortDirection::Desc => {
|
||||
list_threads_from_files_desc(
|
||||
codex_home,
|
||||
fs_page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
archived,
|
||||
search_term,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
if state_db_ctx.is_none() {
|
||||
// Keep legacy behavior when SQLite is unavailable: return filesystem results
|
||||
// at the requested page size.
|
||||
return Ok(truncate_fs_page(fs_page, page_size, sort_key));
|
||||
return Ok(match sort_direction {
|
||||
SortDirection::Asc => fs_page,
|
||||
SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key),
|
||||
});
|
||||
}
|
||||
|
||||
// Warm the DB by repairing every filesystem hit before querying SQLite.
|
||||
@@ -355,6 +370,7 @@ impl RolloutRecorder {
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
sort_direction,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
archived,
|
||||
@@ -367,7 +383,10 @@ impl RolloutRecorder {
|
||||
// If SQLite listing still fails, return the filesystem page rather than failing the list.
|
||||
tracing::error!("Falling back on rollout system");
|
||||
tracing::warn!("state db discrepancy during list_threads_with_db_fallback: falling_back");
|
||||
Ok(truncate_fs_page(fs_page, page_size, sort_key))
|
||||
Ok(match sort_direction {
|
||||
SortDirection::Asc => fs_page,
|
||||
SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key),
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the newest recorded thread path, optionally filtering to a matching cwd.
|
||||
@@ -393,6 +412,7 @@ impl RolloutRecorder {
|
||||
page_size,
|
||||
db_cursor.as_ref(),
|
||||
sort_key,
|
||||
SortDirection::Desc,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
/*archived*/ false,
|
||||
@@ -766,16 +786,252 @@ fn truncate_fs_page(
|
||||
page.items.truncate(page_size);
|
||||
page.next_cursor = page.items.last().and_then(|item| {
|
||||
let file_name = item.path.file_name()?.to_str()?;
|
||||
let (created_at, id) = parse_timestamp_uuid_from_filename(file_name)?;
|
||||
let (created_at, _id) = parse_timestamp_uuid_from_filename(file_name)?;
|
||||
let cursor_token = match sort_key {
|
||||
ThreadSortKey::CreatedAt => format!("{}|{id}", created_at.format(&Rfc3339).ok()?),
|
||||
ThreadSortKey::UpdatedAt => format!("{}|{id}", item.updated_at.as_deref()?),
|
||||
ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?,
|
||||
ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(),
|
||||
};
|
||||
parse_cursor(cursor_token.as_str())
|
||||
});
|
||||
page
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn list_threads_from_files_desc(
|
||||
codex_home: &Path,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
default_provider: &str,
|
||||
archived: bool,
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
if let Some(search_term) = search_term {
|
||||
let mut matching_items = Vec::new();
|
||||
let mut scanned_files = 0usize;
|
||||
let mut reached_scan_cap = false;
|
||||
let mut page_cursor = cursor.cloned();
|
||||
let scan_page_size = page_size.saturating_mul(8).clamp(256, 2048);
|
||||
|
||||
loop {
|
||||
let mut page = list_threads_from_files_desc_unfiltered(
|
||||
codex_home,
|
||||
scan_page_size,
|
||||
page_cursor.as_ref(),
|
||||
sort_key,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
archived,
|
||||
)
|
||||
.await?;
|
||||
scanned_files = scanned_files.saturating_add(page.num_scanned_files);
|
||||
reached_scan_cap |= page.reached_scan_cap;
|
||||
filter_thread_items_by_search_term(codex_home, &mut page.items, Some(search_term))
|
||||
.await?;
|
||||
matching_items.extend(page.items);
|
||||
page_cursor = page.next_cursor;
|
||||
if matching_items.len() > page_size || page_cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let more_matches_available =
|
||||
matching_items.len() > page_size || page_cursor.is_some() || reached_scan_cap;
|
||||
matching_items.truncate(page_size);
|
||||
let next_cursor = if more_matches_available {
|
||||
matching_items
|
||||
.last()
|
||||
.and_then(|item| cursor_from_thread_item(item, sort_key))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
return Ok(ThreadsPage {
|
||||
items: matching_items,
|
||||
next_cursor,
|
||||
num_scanned_files: scanned_files,
|
||||
reached_scan_cap,
|
||||
});
|
||||
}
|
||||
|
||||
list_threads_from_files_desc_unfiltered(
|
||||
codex_home,
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
archived,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn list_threads_from_files_desc_unfiltered(
|
||||
codex_home: &Path,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
default_provider: &str,
|
||||
archived: bool,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
if archived {
|
||||
let root = codex_home.join(ARCHIVED_SESSIONS_SUBDIR);
|
||||
get_threads_in_root(
|
||||
root,
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
ThreadListConfig {
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
layout: ThreadListLayout::Flat,
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
get_threads(
|
||||
codex_home,
|
||||
page_size,
|
||||
cursor,
|
||||
sort_key,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn list_threads_from_files_asc(
|
||||
codex_home: &Path,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
default_provider: &str,
|
||||
archived: bool,
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<ThreadsPage> {
|
||||
let mut all_items = Vec::new();
|
||||
let mut scanned_files = 0usize;
|
||||
let mut reached_scan_cap = false;
|
||||
let mut page_cursor = None;
|
||||
let scan_page_size = page_size.saturating_mul(8).clamp(256, 2048);
|
||||
loop {
|
||||
let page = list_threads_from_files_desc(
|
||||
codex_home,
|
||||
scan_page_size,
|
||||
page_cursor.as_ref(),
|
||||
sort_key,
|
||||
allowed_sources,
|
||||
model_providers,
|
||||
default_provider,
|
||||
archived,
|
||||
/*search_term*/ None,
|
||||
)
|
||||
.await?;
|
||||
scanned_files = scanned_files.saturating_add(page.num_scanned_files);
|
||||
reached_scan_cap |= page.reached_scan_cap;
|
||||
all_items.extend(page.items);
|
||||
page_cursor = page.next_cursor;
|
||||
if page_cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
filter_thread_items_by_search_term(codex_home, &mut all_items, search_term).await?;
|
||||
|
||||
let mut keyed_items = all_items
|
||||
.into_iter()
|
||||
.filter_map(|item| thread_item_sort_key(&item, sort_key).map(|key| (key, item)))
|
||||
.collect::<Vec<_>>();
|
||||
keyed_items.sort_by_key(|(key, _)| *key);
|
||||
let mut all_items = keyed_items
|
||||
.into_iter()
|
||||
.map(|(_, item)| item)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(cursor) = cursor {
|
||||
let anchor = cursor.timestamp();
|
||||
all_items
|
||||
.retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor));
|
||||
}
|
||||
|
||||
let more_matches_available = all_items.len() > page_size || reached_scan_cap;
|
||||
all_items.truncate(page_size);
|
||||
let next_cursor = if more_matches_available {
|
||||
all_items
|
||||
.last()
|
||||
.and_then(|item| cursor_from_thread_item(item, sort_key))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ThreadsPage {
|
||||
items: all_items,
|
||||
next_cursor,
|
||||
num_scanned_files: scanned_files,
|
||||
reached_scan_cap,
|
||||
})
|
||||
}
|
||||
|
||||
async fn filter_thread_items_by_search_term(
|
||||
codex_home: &Path,
|
||||
items: &mut Vec<ThreadItem>,
|
||||
search_term: Option<&str>,
|
||||
) -> std::io::Result<()> {
|
||||
let Some(search_term) = search_term else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// The file-backed fallback only has the thread title in the sidecar session index.
|
||||
// Match the SQLite path's title substring filter so search pagination behaves the same
|
||||
// whether the state DB is available or not.
|
||||
let thread_ids = items
|
||||
.iter()
|
||||
.filter_map(|item| item.thread_id)
|
||||
.collect::<HashSet<_>>();
|
||||
let thread_names = find_thread_names_by_ids(codex_home, &thread_ids).await?;
|
||||
items.retain(|item| {
|
||||
item.thread_id
|
||||
.and_then(|thread_id| thread_names.get(&thread_id))
|
||||
.is_some_and(|title| title.contains(search_term))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn thread_item_sort_key(
|
||||
item: &ThreadItem,
|
||||
sort_key: ThreadSortKey,
|
||||
) -> Option<(OffsetDateTime, uuid::Uuid)> {
|
||||
let file_name = item.path.file_name()?.to_str()?;
|
||||
let (created_at, id) = parse_timestamp_uuid_from_filename(file_name)?;
|
||||
let timestamp = match sort_key {
|
||||
ThreadSortKey::CreatedAt => created_at,
|
||||
ThreadSortKey::UpdatedAt => {
|
||||
let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?;
|
||||
OffsetDateTime::parse(updated_at, &Rfc3339).ok()?
|
||||
}
|
||||
};
|
||||
Some((timestamp, id))
|
||||
}
|
||||
|
||||
fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option<Cursor> {
|
||||
let (timestamp, _id) = thread_item_sort_key(item, sort_key)?;
|
||||
let cursor_token = timestamp.format(&Rfc3339).ok()?;
|
||||
parse_cursor(cursor_token.as_str())
|
||||
}
|
||||
|
||||
struct LogFileInfo {
|
||||
/// Full path to the rollout file.
|
||||
path: PathBuf,
|
||||
|
||||
@@ -372,6 +372,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
|
||||
/*page_size*/ 1,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
SortDirection::Desc,
|
||||
&[],
|
||||
/*model_providers*/ None,
|
||||
default_provider.as_str(),
|
||||
@@ -387,6 +388,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
|
||||
/*page_size*/ 1,
|
||||
Some(&cursor),
|
||||
ThreadSortKey::CreatedAt,
|
||||
SortDirection::Desc,
|
||||
&[],
|
||||
/*model_providers*/ None,
|
||||
default_provider.as_str(),
|
||||
@@ -444,6 +446,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
SortDirection::Desc,
|
||||
&[],
|
||||
/*model_providers*/ None,
|
||||
default_provider.as_str(),
|
||||
@@ -506,6 +509,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul
|
||||
/*page_size*/ 1,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
SortDirection::Desc,
|
||||
&[],
|
||||
/*model_providers*/ None,
|
||||
default_provider.as_str(),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::config::RolloutConfig;
|
||||
use crate::config::RolloutConfigView;
|
||||
use crate::list::Cursor;
|
||||
use crate::list::SortDirection;
|
||||
use crate::list::ThreadSortKey;
|
||||
use crate::metadata;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
@@ -18,7 +18,6 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Core-facing handle to the SQLite-backed state runtime.
|
||||
pub type StateDbHandle = Arc<codex_state::StateRuntime>;
|
||||
@@ -117,21 +116,10 @@ async fn require_backfill_complete(
|
||||
|
||||
fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option<codex_state::Anchor> {
|
||||
let cursor = cursor?;
|
||||
let value = serde_json::to_value(cursor).ok()?;
|
||||
let cursor_str = value.as_str()?;
|
||||
let (ts_str, id_str) = cursor_str.split_once('|')?;
|
||||
if id_str.contains('|') {
|
||||
return None;
|
||||
}
|
||||
let id = Uuid::parse_str(id_str).ok()?;
|
||||
let ts = if let Ok(naive) = NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H-%M-%S") {
|
||||
DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)
|
||||
} else if let Ok(dt) = DateTime::parse_from_rfc3339(ts_str) {
|
||||
dt.with_timezone(&Utc)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(codex_state::Anchor { ts, id })
|
||||
let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000;
|
||||
let millis = i64::try_from(millis).ok()?;
|
||||
let ts = chrono::DateTime::<Utc>::from_timestamp_millis(millis)?;
|
||||
Some(codex_state::Anchor { ts })
|
||||
}
|
||||
|
||||
pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf {
|
||||
@@ -200,6 +188,7 @@ pub async fn list_threads_db(
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
sort_direction: SortDirection,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
archived: bool,
|
||||
@@ -227,15 +216,21 @@ pub async fn list_threads_db(
|
||||
match ctx
|
||||
.list_threads(
|
||||
page_size,
|
||||
anchor.as_ref(),
|
||||
match sort_key {
|
||||
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
|
||||
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
|
||||
codex_state::ThreadFilterOptions {
|
||||
archived_only: archived,
|
||||
allowed_sources: allowed_sources.as_slice(),
|
||||
model_providers: model_providers.as_deref(),
|
||||
anchor: anchor.as_ref(),
|
||||
sort_key: match sort_key {
|
||||
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
|
||||
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
|
||||
},
|
||||
sort_direction: match sort_direction {
|
||||
SortDirection::Asc => codex_state::SortDirection::Asc,
|
||||
SortDirection::Desc => codex_state::SortDirection::Desc,
|
||||
},
|
||||
search_term,
|
||||
},
|
||||
allowed_sources.as_slice(),
|
||||
model_providers.as_deref(),
|
||||
archived,
|
||||
search_term,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user