mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84f731c38d |
No files matched your search
@@ -1,23 +0,0 @@
|
||||
[[hooks]]
|
||||
name = "shell guard (staging, release freeze)"
|
||||
event = "tool_call_before"
|
||||
command = 'cd "$DEEPSEEK_WORKSPACE" && echo "4c8e10ebe99524e6a5b2f0fbb090a8f4881f95b2bf732f020c566a610f949aa4 scripts/agent-guard.py" | shasum -a 256 -c -s && exec python3 scripts/agent-guard.py'
|
||||
timeout_secs = 10
|
||||
continue_on_error = true
|
||||
condition = { type = "tool_name", name = "bash" }
|
||||
|
||||
[[hooks]]
|
||||
name = "edit guard (CODEWHALE_RELEASE_FREEZE=1)"
|
||||
event = "tool_call_before"
|
||||
command = '[ -z "$CODEWHALE_RELEASE_FREEZE" ] || { cd "$DEEPSEEK_WORKSPACE" && echo "4c8e10ebe99524e6a5b2f0fbb090a8f4881f95b2bf732f020c566a610f949aa4 scripts/agent-guard.py" | shasum -a 256 -c -s && exec python3 scripts/agent-guard.py; }'
|
||||
timeout_secs = 10
|
||||
continue_on_error = true
|
||||
|
||||
[hooks.condition]
|
||||
type = "any"
|
||||
conditions = [
|
||||
{ type = "tool_category", category = "file_write" },
|
||||
{ type = "tool_name", name = "write_file" },
|
||||
{ type = "tool_name", name = "edit_file" },
|
||||
{ type = "tool_name", name = "fim_edit" },
|
||||
]
|
||||
@@ -73,14 +73,14 @@ filter = 'binary(telemetry_kill_switch_dispatch)'
|
||||
test-group = 'telemetry-contract'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(codewhale-engine) & kind(lib) & test(/^fleet::manager::tests::/)'
|
||||
filter = 'package(codewhale-tui) & kind(lib) & test(/^fleet::manager::tests::/)'
|
||||
test-group = 'fleet-manager-lifecycle'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
# DSH preparation also launches the isolated host against the shared hermetic
|
||||
# home. OCR/PDF consumers also launch real hosts outside extension_host's
|
||||
# namespace. Keep their process lifecycle checks in this existing serial lane.
|
||||
filter = '(package(codewhale-engine) | package(codewhale-tui)) & kind(lib) & (test(/^extension_host::/) | test(/^tools::image_ocr::host_tests::/) | test(/^tools::pdf::host_tests::/) | test(/^core::engine::.*extension/) | test(/^plugins::install::dsh::/) | test(/^plugins::tests::dsh_/) | test(/^runtime_api::tests::dsh_package_preview_then_exact_install_over_http$/) | test(/^commands::contract::tests::plugin_adapter_dsh_preview_runs_from_live_runtime$/) | test(/^commands::groups::plugins::tests::dsh_import_reviews_without_installing_then_installs_the_exact_bundle$/))'
|
||||
filter = 'package(codewhale-tui) & kind(lib) & (test(/^extension_host::/) | test(/^tools::image_ocr::host_tests::/) | test(/^tools::pdf::host_tests::/) | test(/^core::engine::.*extension/) | test(/^plugins::install::dsh::/) | test(/^plugins::tests::dsh_/) | test(/^runtime_api::tests::dsh_package_preview_then_exact_install_over_http$/) | test(/^commands::contract::tests::plugin_adapter_dsh_preview_runs_from_live_runtime$/) | test(/^commands::groups::plugins::tests::dsh_import_reviews_without_installing_then_installs_the_exact_bundle$/))'
|
||||
test-group = 'extension-host'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
@@ -116,5 +116,5 @@ path = "junit.xml"
|
||||
report-name = "codewhale-native-host"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(codewhale-engine) & kind(lib) & (test(/^extension_host::tests::compiled_native_host_cannot_read_secrets_or_write_outside_its_data_dir$/) | test(/^extension_host::tests::compiled_native_host_memory_cap_is_enforced$/))'
|
||||
filter = 'package(codewhale-tui) & kind(lib) & (test(/^extension_host::tests::compiled_native_host_cannot_read_secrets_or_write_outside_its_data_dir$/) | test(/^extension_host::tests::compiled_native_host_memory_cap_is_enforced$/))'
|
||||
junit.store-success-output = true
|
||||
+6
-12
@@ -3,9 +3,9 @@
|
||||
# produces different compiled binaries on Windows vs Linux/macOS.
|
||||
# Cover nested mode/approval/personality prompts — a top-level *.md glob alone
|
||||
# left modes/*.md on CRLF under Windows autocrlf and blew the agent token budget.
|
||||
crates/engine/src/prompts/**/*.md text eol=lf
|
||||
crates/engine/src/prompts/*.md text eol=lf
|
||||
crates/engine/src/prompts/*.txt text eol=lf
|
||||
crates/tui/src/prompts/**/*.md text eol=lf
|
||||
crates/tui/src/prompts/*.md text eol=lf
|
||||
crates/tui/src/prompts/*.txt text eol=lf
|
||||
|
||||
# The rest of the include_str!() inputs. `* text=auto` below converts these to
|
||||
# CRLF on a Windows checkout, which is invisible for the ones that are only
|
||||
@@ -58,12 +58,11 @@ scripts/fixtures/dsh-web-app/** text eol=lf
|
||||
# the generator's LF output (protocol/tests.rs), and its dist bundle is
|
||||
# include_bytes!()'d and digest-checked at launch; a CRLF Windows checkout
|
||||
# failed the former and would embed different bytes.
|
||||
crates/engine/extension-host/src/protocol.generated.ts text eol=lf
|
||||
crates/engine/extension-host/dist/** text eol=lf
|
||||
crates/tui/extension-host/src/protocol.generated.ts text eol=lf
|
||||
crates/tui/extension-host/dist/** text eol=lf
|
||||
# Reviewed extension-host fixtures carry exact source SHA-256 receipts.
|
||||
# A Windows checkout must not rewrite their admitted source closure to CRLF.
|
||||
crates/engine/tests/fixtures/extension_host/** text eol=lf
|
||||
crates/engine/extension-host/src/dsh/dsh-message-compat.js text eol=lf
|
||||
crates/tui/tests/fixtures/extension_host/** text eol=lf
|
||||
|
||||
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
|
||||
*.rs text eol=lf
|
||||
@@ -80,8 +79,3 @@ scripts/release/*.sh text eol=lf
|
||||
|
||||
# Preserve the qualified upstream font license, including its trailing space.
|
||||
web/public/brand/fonts/OFL.txt text eol=lf whitespace=-blank-at-eol
|
||||
|
||||
# Generated protocol and SDK exports are compared byte for byte; keep LF on Windows checkouts.
|
||||
crates/app-server-protocol/schema/** text eol=lf
|
||||
crates/app-server-protocol/METHODS.md text eol=lf
|
||||
npm/runtime-sdk/generated/** text eol=lf
|
||||
@@ -0,0 +1,77 @@
|
||||
# Scoped contribution-gate allowlist.
|
||||
#
|
||||
# Maintainers and collaborators bypass the gate automatically. Use this file
|
||||
# for external contributors who are allowed through the automated front door.
|
||||
# Seed active contributors here before switching the gate workflows to enforce mode.
|
||||
#
|
||||
# Supported entries:
|
||||
# pr:username
|
||||
# issue:username
|
||||
# all:username
|
||||
all:hmbown
|
||||
all:reidliu41
|
||||
all:ousamabenyounes
|
||||
all:ljm3790865
|
||||
all:HUQIANTAO
|
||||
all:xyuai
|
||||
all:merchloubna70-dot
|
||||
all:h3c-hexin
|
||||
all:axobase001
|
||||
all:donglovejava
|
||||
all:Oliver-ZPLiu
|
||||
all:idling11
|
||||
all:angziii
|
||||
all:aboimpinto
|
||||
all:encyc
|
||||
all:Duducoco
|
||||
all:cyq1017
|
||||
all:zlh124
|
||||
all:THINKER-ONLY
|
||||
all:nightt5879
|
||||
all:Liu-Vince
|
||||
all:JiarenWang
|
||||
all:wdw8276
|
||||
all:pengyou200902
|
||||
all:linzhiqin2003
|
||||
all:LING71671
|
||||
all:JasonOA888
|
||||
all:Inference1
|
||||
all:hongqitai
|
||||
all:gordonlu
|
||||
all:gaord
|
||||
all:zhuangbiaowei
|
||||
all:yuanchenglu
|
||||
all:Vishnu1837
|
||||
all:sximelon
|
||||
all:Sskift
|
||||
all:New2Niu
|
||||
all:shenjackyuanjie
|
||||
all:AdityaVG13
|
||||
all:mvanhorn
|
||||
all:MengZ-super
|
||||
all:membphis
|
||||
all:LeoAlex0
|
||||
all:Lee-take
|
||||
all:lbcheng888
|
||||
all:Implementist
|
||||
all:jrcjrcc
|
||||
all:yusufgurdogan
|
||||
all:kunpeng-ai-lab
|
||||
all:elowen53
|
||||
all:CrepuscularIRIS
|
||||
all:chnjames
|
||||
all:ChaceLyee2101
|
||||
all:AresNing
|
||||
pr:dmitri-0
|
||||
pr:sparkofspike
|
||||
pr:angel-hair
|
||||
pr:wuisabel-gif
|
||||
all:whp233
|
||||
all:musichen
|
||||
all:M-Maciej
|
||||
all:serephus
|
||||
all:Pinvou
|
||||
all:SparkofSpike
|
||||
all:goransh-walia
|
||||
all:xiechimon
|
||||
all:yrk111222
|
||||
@@ -242,8 +242,6 @@ huangxianzhan = huangxianzhan <[email protected]>
|
||||
[email protected] = huangxianzhan <[email protected]>
|
||||
zhuowp = zhuowp <[email protected]>
|
||||
[email protected] = zhuowp <[email protected]>
|
||||
z27014 = zhuowp <[email protected]>
|
||||
[email protected] = zhuowp <[email protected]>
|
||||
[email protected] = 秋月凉梦 <[email protected]>
|
||||
|
||||
asto18089 = asto18089 <[email protected]>
|
||||
@@ -272,9 +270,3 @@ Henrik Bakke Dukefoss = Henrik Bakke Dukefoss <[email protected]
|
||||
[email protected] = Henrik Bakke Dukefoss <[email protected]>
|
||||
|
||||
[email protected] = Dhruv Singal <[email protected]>
|
||||
[email protected] = Boyce <[email protected]>
|
||||
Boyce = Boyce <[email protected]>
|
||||
Felixkw12 = Felix Wunderlich <[email protected]>
|
||||
[email protected] = Felix Wunderlich <[email protected]>
|
||||
Felix Wunderlich = Felix Wunderlich <[email protected]>
|
||||
[email protected] = luochen211 <[email protected]>
|
||||
+45
-51
@@ -1,68 +1,62 @@
|
||||
# Berkontribusi pada Codewhale
|
||||
|
||||
Codewhale berkembang berkat orang yang menggunakannya, melaporkan masalah,
|
||||
menerjemahkan dokumentasi, menambahkan penyedia model, dan mengirim perbaikan.
|
||||
Anda dapat berkontribusi tanpa menulis kode. Semua masukan diterima.
|
||||
Terima kasih atas minat Anda untuk berkontribusi pada Codewhale! Dokumen ini memberikan panduan dan instruksi untuk berkontribusi.
|
||||
|
||||
[Roadmap](../docs/ROADMAP.md) menjelaskan tujuan proyek dan pekerjaan yang masih
|
||||
terbuka. [Panduan utama](../CONTRIBUTING.md) memuat prosedur pengembangan,
|
||||
pengujian, dan peninjauan yang lengkap dan terbaru.
|
||||
---
|
||||
|
||||
## Pilih tempat yang sesuai
|
||||
## Memulai (Getting Started)
|
||||
|
||||
| Yang ingin Anda sampaikan | Tempatnya |
|
||||
| --- | --- |
|
||||
| Bug, crash, atau perilaku yang tidak diharapkan | [Laporan bug](https://github.com/codewhale-hq/CodeWhale/issues/new?template=bug_report.yml) |
|
||||
| Ide atau permintaan fitur | [Permintaan fitur](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml) |
|
||||
| Masukan tentang tampilan, tulisan, atau pengalaman penggunaan | [Formulir masukan](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feedback.yml) |
|
||||
| Pertanyaan | [Tanya jawab](https://github.com/codewhale-hq/CodeWhale/discussions/categories/q-a) atau [Discord](https://discord.gg/37gfS3ksug) |
|
||||
| Masalah keamanan | [Laporan keamanan privat](https://github.com/codewhale-hq/CodeWhale/security/advisories/new), bukan issue publik |
|
||||
| Perbaikan untuk issue yang sudah ada | Pull request yang menautkan issue tersebut |
|
||||
| Terjemahan, penyedia model, atau perbaikan dokumentasi | Pull request; tidak perlu membuka issue terlebih dahulu |
|
||||
### Prasyarat
|
||||
|
||||
Jika ragu, buka issue dan jelaskan apa yang Anda coba lakukan, apa yang terjadi,
|
||||
dan apa yang Anda harapkan. Pengelola akan membantu mengarahkannya.
|
||||
- Rust 1.88 atau lebih baru (edisi 2024)
|
||||
- Pengelola paket Cargo
|
||||
- Git
|
||||
|
||||
## Mulai fitur baru dengan issue
|
||||
### Menyiapkan Lingkungan Pengembangan
|
||||
|
||||
Untuk fitur baru, buka issue sebelum mulai menulis kode. Jelaskan kebutuhan dan
|
||||
hambatannya; rancangan solusi boleh disertakan, tetapi tidak wajib. Jika ingin
|
||||
mengerjakannya sendiri, centang pilihan untuk membuka pull request pada formulir.
|
||||
1. Fork dan klon repositori:
|
||||
```bash
|
||||
git clone https://github.com/USERNAME_ANDA/CodeWhale.git
|
||||
cd CodeWhale
|
||||
```
|
||||
|
||||
Pengelola dapat membahas cakupan dan perilaku fitur sebelum Anda menghabiskan
|
||||
waktu untuk implementasi. Pull request fitur tanpa issue tetap dibaca, tetapi
|
||||
pembahasannya biasanya membutuhkan waktu lebih lama.
|
||||
2. Kompilasi proyek:
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
## Pekerjaan yang dapat langsung Anda kirim
|
||||
3. Jalankan pengujian (tests):
|
||||
```bash
|
||||
cargo test --workspace --all-features
|
||||
```
|
||||
|
||||
Anda dapat langsung mengirim pull request untuk terjemahan, penyedia dan model,
|
||||
perbaikan dokumentasi, atau perbaikan bug. Jika bug belum memiliki issue,
|
||||
jelaskan cara memunculkannya, hasil yang terjadi, dan hasil yang diharapkan di
|
||||
pull request.
|
||||
4. Jalankan dalam mode pengembangan:
|
||||
```bash
|
||||
cargo run --bin codewhale
|
||||
```
|
||||
|
||||
Untuk pekerjaan yang sudah direncanakan, pilih issue di
|
||||
[milestone](../docs/ROADMAP.md#releases) atau yang berlabel
|
||||
[help wanted](https://github.com/codewhale-hq/CodeWhale/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).
|
||||
Beri komentar bahwa Anda ingin mengerjakannya agar pekerjaan tidak tumpang tindih.
|
||||
[Lihat proyek terbuka](../CONTRIBUTING.md#standing-projects) untuk tautan ke
|
||||
panduan terjemahan dan penyedia model.
|
||||
---
|
||||
|
||||
## Siapkan perubahan Anda
|
||||
## Alur Kerja Pengembangan
|
||||
|
||||
Ikuti [petunjuk persiapan pengembangan](../CONTRIBUTING.md#getting-started)
|
||||
untuk persyaratan Rust, perintah build, dan cara menjalankan versi sumber.
|
||||
Versi di `main` dapat lebih baru daripada unduhan rilis; sertakan keluaran
|
||||
`codewhale --version` ketika melaporkan masalah.
|
||||
### Gaya Kode (Code Style)
|
||||
|
||||
Jalankan pemeriksaan yang sesuai dengan bagian yang Anda ubah, mengikuti
|
||||
[panduan pengujian lokal](../CONTRIBUTING.md#fast-local-loop). Sebelum mengirim
|
||||
pull request, baca perubahan Anda sendiri dan jelaskan apa yang berubah serta
|
||||
bagaimana Anda memeriksanya. Jangan sertakan kunci API atau data pribadi.
|
||||
- Jalankan `cargo fmt` sebelum melakukan commit untuk memastikan format kode konsisten.
|
||||
- Jalankan `cargo clippy` dan atasi seluruh peringatan.
|
||||
- Ikuti konvensi penamaan Rust (`snake_case` untuk fungsi/variabel, `CamelCase` untuk tipe data/struct).
|
||||
- Tambahkan komentar dokumentasi untuk API publik.
|
||||
|
||||
## Peninjauan dan kredit
|
||||
### Pengujian
|
||||
|
||||
Pull request Anda digabungkan sebagai kontribusi Anda sendiri. Pengelola
|
||||
membantu menyelesaikan konflik akibat perubahan di `main`; pemeriksaan yang
|
||||
merah karena masalah lain bukan tanggung jawab Anda. Jika perubahan belum siap,
|
||||
pengelola menjelaskan alasannya. Kontribusi dicatat di changelog dan
|
||||
[daftar kontributor](../docs/CONTRIBUTORS.md).
|
||||
- Tulis pengujian untuk fungsionalitas baru.
|
||||
- Pastikan seluruh pengujian yang ada lulus: `cargo test --workspace --all-features`.
|
||||
- Tempatkan unit test di samping kode yang diuji (`#[cfg(test)]`), serta tambahkan pengujian integrasi di bawah direktori `tests/` pada crate yang bersangkutan.
|
||||
|
||||
---
|
||||
|
||||
## Pengajuan Pull Request (PR)
|
||||
|
||||
1. Buat branch baru dari `main` untuk fitur atau perbaikan Anda.
|
||||
2. Pastikan `cargo fmt`, `cargo clippy`, dan pengujian lulus sebelum mengajukan PR.
|
||||
3. Tulis judul dan deskripsi PR yang jelas yang menjelaskan tujuan perubahan.
|
||||
4. Setiap kontributor akan mendapatkan kredit dalam changelog dan `docs/CONTRIBUTORS.md`.
|
||||
@@ -3,9 +3,6 @@ contact_links:
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/codewhale-hq/CodeWhale/security/advisories/new
|
||||
about: Please report security problems privately, not in a public issue. See SECURITY.md.
|
||||
- name: Roadmap and where to help
|
||||
url: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/ROADMAP.md
|
||||
about: Where Codewhale is going, what each release covers, and good places to start. 路线图。
|
||||
- name: Ask a question
|
||||
url: https://github.com/codewhale-hq/CodeWhale/discussions
|
||||
about: How-to questions and ideas you want to talk through. 提问和讨论。
|
||||
|
||||
@@ -7,8 +7,6 @@ body:
|
||||
value: |
|
||||
Thanks for the idea. English or 中文 are both fine. Short is good.
|
||||
|
||||
This is the best place to start a feature, even if you plan to build it yourself: agreeing on the shape here makes the pull request small and quick to merge. The [roadmap](https://github.com/codewhale-hq/CodeWhale/blob/main/docs/ROADMAP.md) shows what is already planned.
|
||||
|
||||
If someone already asked for this, add a 👍 to their issue instead.
|
||||
Want to talk it through first? Use [Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or [Discord](https://discord.gg/37gfS3ksug).
|
||||
|
||||
@@ -63,4 +61,4 @@ body:
|
||||
attributes:
|
||||
label: Contributing
|
||||
options:
|
||||
- label: I would like to open a pull request for this, once we agree on the shape. 我愿意在方案确定后提交 PR。
|
||||
- label: I would like to open a pull request for this. 我愿意提交 PR。
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Feedback
|
||||
description: Something looked, read or felt off, or you have a thought to share. 反馈与感受。
|
||||
labels: ["needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
All feedback is welcome, and you do not need a fix or a plan. English or 中文 are both fine.
|
||||
|
||||
First impressions, confusing wording, an awkward step, a screen that looks wrong, something you liked: it all helps.
|
||||
For something broken, the [bug report](https://github.com/codewhale-hq/CodeWhale/issues/new?template=bug_report.yml) form asks for the details we need to reproduce it.
|
||||
|
||||
- type: textarea
|
||||
id: feedback
|
||||
attributes:
|
||||
label: What would you like us to know? 想告诉我们什么?
|
||||
description: What you were doing, and what you noticed. A screenshot helps for anything visual.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Codewhale version (optional) 版本
|
||||
description: Output of `codewhale --version`, if you have it.
|
||||
@@ -4,20 +4,22 @@
|
||||
|
||||
<!-- What does this change, and why? A few sentences is enough. -->
|
||||
|
||||
## Where this came from
|
||||
## Issue
|
||||
|
||||
<!-- Closes #N if this finishes an issue, Refs #N if related. Translations,
|
||||
provider and model updates, docs fixes and bug fixes need no issue: just say
|
||||
which it is. For a new feature with no issue yet, opening one first is the
|
||||
faster path (CONTRIBUTING.md explains why); we still read this PR either way.
|
||||
Please do not write a closing word inside a negation ("this does not close #N"):
|
||||
GitHub closes the issue anyway. -->
|
||||
<!-- CI needs one line here. Replace N with a number:
|
||||
- Closes #N this PR finishes the issue (Fixes / Resolves also work)
|
||||
- Refs #N related or partial work; the issue stays open
|
||||
- No-Issue: <one-line reason> for typos, chores, dependency bumps
|
||||
Only write a closing word when you mean it. GitHub closes the issue even in
|
||||
"this does not close ..." and CI rejects that wording.
|
||||
Put issue numbers here rather than in `feat:` commit messages: CI requires a
|
||||
`feat:` commit that mentions #N to also add #N to CHANGELOG.md. -->
|
||||
|
||||
## How I tested it
|
||||
|
||||
<!-- Commands you ran and what they showed. Test what you changed, for example
|
||||
`scripts/dev-test.sh tui <filter>`. A few quick checks run on your PR; if one
|
||||
fails for a reason unrelated to your change, a maintainer will deal with it.
|
||||
`scripts/dev-test.sh tui <filter>`; the PR checks run fmt, clippy, the Linux
|
||||
suite and a Windows build for you (CONTRIBUTING.md lists them).
|
||||
For visible UI changes, add a screenshot or recording. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
@@ -5,10 +5,8 @@ repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "${repo}"
|
||||
|
||||
template=".github/ISSUE_TEMPLATE/agent-task.yml"
|
||||
retired_workflows=(
|
||||
".github/workflows/v0868-milestone-sync.yml"
|
||||
".github/workflows/agent-task-labels.yml"
|
||||
)
|
||||
workflow=".github/workflows/agent-task-labels.yml"
|
||||
retired_workflow=".github/workflows/v0868-milestone-sync.yml"
|
||||
|
||||
fail() {
|
||||
echo "agent-task metadata contract failed: $*" >&2
|
||||
@@ -16,9 +14,8 @@ fail() {
|
||||
}
|
||||
|
||||
[[ -f "${template}" ]] || fail "missing ${template}"
|
||||
for retired in "${retired_workflows[@]}"; do
|
||||
[[ ! -e "${retired}" ]] || fail "retired workflow still exists: ${retired}"
|
||||
done
|
||||
[[ -f "${workflow}" ]] || fail "missing ${workflow}"
|
||||
[[ ! -e "${retired_workflow}" ]] || fail "retired milestone sync still exists"
|
||||
|
||||
if grep -nE '^(title|labels):.*v[0-9]+\.[0-9]+\.[0-9]+' "${template}"; then
|
||||
fail "agent-task defaults must not pin a release version"
|
||||
@@ -26,5 +23,17 @@ fi
|
||||
|
||||
grep -qF 'labels: ["agent-ready"]' "${template}" \
|
||||
|| fail "agent-task template must opt into agent-ready without a version label"
|
||||
grep -qF 'types: [opened]' "${workflow}" \
|
||||
|| fail "agent-task labeling must run only when an issue is opened"
|
||||
grep -qF "labels.has('agent-ready')" "${workflow}" \
|
||||
|| fail "agent-task labeling must be gated by the explicit agent-ready label"
|
||||
|
||||
if grep -nE 'listMilestones|issues\.update|^[[:space:]]*milestone:' "${workflow}"; then
|
||||
fail "agent-task automation must never read or assign milestones"
|
||||
fi
|
||||
|
||||
if grep -nE 'types:.*(labeled|milestoned)' "${workflow}"; then
|
||||
fail "agent-task automation must not react to later label or milestone edits"
|
||||
fi
|
||||
|
||||
echo "Agent-task metadata is release-neutral and milestone-safe."
|
||||
@@ -69,7 +69,7 @@ assert.match(
|
||||
"Engine acceptance must build the canonical executable first",
|
||||
);
|
||||
const npmSmokeJob = ci.match(/^ npm-wrapper-smoke:\n([\s\S]*?)(?=^ \S)/m)?.[1];
|
||||
assert.ok(npmSmokeJob, "CI must retain the npm-wrapper job");
|
||||
assert.ok(npmSmokeJob, "CI must retain the required npm-wrapper job");
|
||||
assert.match(
|
||||
namedStep(npmSmokeJob, "Build wrapper binaries"),
|
||||
/run: cargo build --release --locked -p codewhale-cli --bin codewhale/,
|
||||
@@ -79,19 +79,19 @@ assert.match(
|
||||
/run: node scripts\/release\/npm-wrapper-smoke\.js/,
|
||||
);
|
||||
const npmSmokeSteps = npmSmokeJob.split(/(?=^ - )/m).slice(1);
|
||||
// Exercise the workflow's actual Boolean guards. The smoke is manual-only
|
||||
// (workflow_dispatch); pull requests, pushes and schedules skip the job.
|
||||
// Exercise the workflow's actual Boolean guards. A successful location echo
|
||||
// must never substitute for the build/install smoke on a heavy pull request.
|
||||
const npmSmokeCases = [
|
||||
// name, event, heavy, OS, trusted, cache success, execute, Linux deps, CNB
|
||||
["own PR", "pull_request", true, "ubuntu-latest", true, true, false, false, false],
|
||||
["fork PR", "pull_request", true, "ubuntu-latest", false, true, false, false, false],
|
||||
["PR cache failure", "pull_request", true, "ubuntu-latest", false, false, false, false, false],
|
||||
["own PR", "pull_request", true, "ubuntu-latest", true, true, true, true, false],
|
||||
["fork PR", "pull_request", true, "ubuntu-latest", false, true, true, true, false],
|
||||
["PR cache failure", "pull_request", true, "ubuntu-latest", false, false, true, true, false],
|
||||
["light PR", "pull_request", false, "ubuntu-latest", true, true, false, false, false],
|
||||
["manual Ubuntu", "workflow_dispatch", true, "ubuntu-latest", true, true, true, true, false],
|
||||
["main Ubuntu", "push", true, "ubuntu-latest", true, true, false, false, false],
|
||||
["main Ubuntu", "push", true, "ubuntu-latest", true, true, false, false, true],
|
||||
["manual macOS", "workflow_dispatch", true, "macos-latest", true, true, true, false, false],
|
||||
["main Windows", "push", true, "windows-latest", true, true, false, false, false],
|
||||
["main cache failure", "push", true, "windows-latest", true, false, false, false, false],
|
||||
["main Windows", "push", true, "windows-latest", true, true, true, false, false],
|
||||
["main cache failure", "push", true, "windows-latest", true, false, true, false, false],
|
||||
["light main", "push", false, "ubuntu-latest", true, true, false, false, false],
|
||||
["schedule", "schedule", true, "ubuntu-latest", true, true, false, false, false],
|
||||
];
|
||||
@@ -121,7 +121,6 @@ for (const [label, event, heavy, os, trusted, cache, execute, linuxDeps, cnb] of
|
||||
else if (name === "Linux smoke location") expected = cnb;
|
||||
else if (name === sccacheInstallStep) expected = installed;
|
||||
else if (name === "Enable sccache" || name === "sccache stats") expected = installed && cache;
|
||||
if (!jobEnabled) expected = false;
|
||||
assert.equal(
|
||||
Boolean(jobEnabled && vm.runInNewContext(guard, context)),
|
||||
expected,
|
||||
@@ -129,14 +128,15 @@ for (const [label, event, heavy, os, trusted, cache, execute, linuxDeps, cnb] of
|
||||
);
|
||||
}
|
||||
}
|
||||
// The matrix itself: a manual full-CI dispatch covers Ubuntu and Windows; the
|
||||
// release artifacts cover macOS.
|
||||
// The matrix itself: pull requests keep the single required Ubuntu context,
|
||||
// main pushes skip the hosted-macOS leg (5-job concurrency cap), and a manual
|
||||
// full-CI dispatch still covers all three platforms.
|
||||
const npmSmokeMatrix = npmSmokeJob.match(/^ os: \$\{\{ fromJSON\((.+)\) \}\}$/m)?.[1];
|
||||
assert.ok(npmSmokeMatrix, "the wrapper job must keep an event-keyed OS matrix");
|
||||
for (const [event, expected] of [
|
||||
["pull_request", ["ubuntu-latest"]],
|
||||
["push", ["ubuntu-latest", "windows-latest"]],
|
||||
["workflow_dispatch", ["ubuntu-latest", "windows-latest"]],
|
||||
["workflow_dispatch", ["ubuntu-latest", "macos-latest", "windows-latest"]],
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
JSON.parse(vm.runInNewContext(npmSmokeMatrix, { github: { event_name: event } })),
|
||||
@@ -362,7 +362,6 @@ const expectedTargets = [
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"aarch64-linux-android",
|
||||
"aarch64-unknown-linux-ohos",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
@@ -396,7 +395,7 @@ const builtAssetNames = [
|
||||
...valuesForKey(artifacts, "shim_artifact"),
|
||||
...valuesForKey(artifacts, "compat_tui_artifact"),
|
||||
];
|
||||
assert.equal(builtAssetNames.length, 24);
|
||||
assert.equal(builtAssetNames.length, 21);
|
||||
assert.deepEqual(
|
||||
[...new Set(builtAssetNames)].sort(),
|
||||
[
|
||||
@@ -418,44 +417,6 @@ const bundleInvocations = [...bundles.matchAll(
|
||||
assert.deepEqual(bundleInvocations.sort(), [...BUNDLE_ASSET_NAMES].sort());
|
||||
assert.match(artifacts, /aarch64-pc-windows-msvc/);
|
||||
assert.match(artifacts, /aarch64-linux-android/);
|
||||
// HarmonyOS bytes cannot be shipped unsigned: the device refuses to execute an
|
||||
// unsigned ELF, so the leg must self-sign and prove the signature before
|
||||
// staging. Assert the order too — signing after staging would publish the
|
||||
// unsigned copy.
|
||||
assert.match(artifacts, /aarch64-unknown-linux-ohos/);
|
||||
const ohosSdk = namedStep(artifacts, "Install OpenHarmony native SDK");
|
||||
assert.match(ohosSdk, /components: 'native;toolchains'/, "the SDK must include the binary signer");
|
||||
assert.match(ohosSdk, /cache: false/, "a release must download the SDK, not publish a new cache entry");
|
||||
// A step header glued onto the previous line because a `run:` block lost its
|
||||
// trailing newline parses as valid YAML (the header becomes script text) and
|
||||
// silently drops the step. Every `- name:` in these workflows must start its
|
||||
// own line.
|
||||
for (const [file, source] of [
|
||||
["release-artifacts.yml", artifacts],
|
||||
["release.yml", release],
|
||||
["nightly.yml", nightly],
|
||||
]) {
|
||||
for (const match of source.matchAll(/- name:/g)) {
|
||||
const lineStart = source.lastIndexOf("\n", match.index) + 1;
|
||||
assert.match(
|
||||
source.slice(lineStart, match.index),
|
||||
/^\s*$/,
|
||||
`${file}: a step header must start its own line, not follow other content`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ohosBuild = namedStep(artifacts, "Cross-compile and code-sign the OpenHarmony binary");
|
||||
assert.match(ohosBuild, /matrix\.platform == 'ohos-arm64'/);
|
||||
assert.match(ohosBuild, /binary-sign-tool\.jar/);
|
||||
assert.match(ohosBuild, /-selfSign 1/);
|
||||
assert.match(ohosBuild, /code signature is self-sign/);
|
||||
assert.match(ohosBuild, /scripts\/ohos-env\.sh/);
|
||||
assert.ok(
|
||||
ohosBuild.indexOf("codesign") === -1 ||
|
||||
ohosBuild.indexOf("binary-sign-tool.jar") < ohosBuild.indexOf("display-sign"),
|
||||
"the signature must be applied before it is verified",
|
||||
);
|
||||
assert.match(artifacts, /codew-windows-arm64\.exe/);
|
||||
assert.match(artifacts, /CodeWhaleSetup\.exe/);
|
||||
assert.match(artifacts, /assemble-release-assets\.js --verify release-assets/);
|
||||
@@ -530,7 +491,7 @@ const parityRustCache = [...parity[1].matchAll(/uses: Swatinem\/rust-cache@[\s\S
|
||||
assert.equal(parityRustCache.length, 1, "parity must pin exactly one rust-cache");
|
||||
assert.doesNotMatch(parityRustCache[0], /github\.(event|ref|sha)|inputs\./);
|
||||
|
||||
assert.equal(allReleaseAssetNames().length, 38);
|
||||
assert.equal(allReleaseAssetNames().length, 34);
|
||||
assert.match(release, /^ artifacts:\n/m);
|
||||
assert.match(release, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
|
||||
assert.doesNotMatch(release, /^ (build|bundle|windows-installer):/m);
|
||||
@@ -659,7 +620,7 @@ assert.doesNotMatch(
|
||||
|
||||
assert.match(runbook, /release[- ]candidate/i);
|
||||
assert.match(runbook, /expected_sha/);
|
||||
assert.match(runbook, /38/);
|
||||
assert.match(runbook, /34/);
|
||||
assert.match(runbook, /does not create a tag/i);
|
||||
assert.match(runbook, /explicit.*approval/i);
|
||||
assert.match(runbook, /last[- ]useful[- ]log/i, "runbook must document the last-useful-log rule (#5496)");
|
||||
@@ -789,10 +750,18 @@ assert.equal(
|
||||
2,
|
||||
"both glibc recovery branches must name codewhale-cli",
|
||||
);
|
||||
// The archive installer never overwrites an existing command: it validates the
|
||||
// retired TUI path against the consolidated bytes and leaves upgrades to
|
||||
// `codewhale update`, which migrates `codewhale-tui` beside the canonical pair.
|
||||
assert.match(
|
||||
archiveInstaller,
|
||||
/legacy_tui="\$BIN_DIR\/codewhale-tui"[\s\S]*check_destination "\$SCRIPT_DIR\/codewhale" "\$legacy_tui"/,
|
||||
"archive installs must validate the retired TUI path against consolidated bytes",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
archiveInstaller,
|
||||
/install_binary "\$SCRIPT_DIR\/[^"]+" "\$BIN_DIR\/codewhale-tui"/,
|
||||
"archive installs must not write the retired codewhale-tui command",
|
||||
/install_binary "\$SCRIPT_DIR\/codewhale" "\$legacy_tui"/,
|
||||
"archive installs must not overwrite an existing retired TUI command",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
cliDispatcher,
|
||||
@@ -905,5 +874,5 @@ assert.ok(
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 8-target/38-asset release candidate.",
|
||||
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 7-target/34-asset release candidate.",
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Agent task labels
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labels:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Add area hints to agent tasks
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
if (!issue) return;
|
||||
|
||||
const labels = new Set((issue.labels || []).map(label => label.name));
|
||||
if (!labels.has('agent-ready')) {
|
||||
core.info(`#${issue.number} is not an agent task; leaving it unchanged.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Milestones are the canonical release roadmap and are assigned
|
||||
// explicitly during triage. Never infer one from a title or label:
|
||||
// old version labels must remain harmless historical metadata.
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const text = `${title}\n${body}`;
|
||||
|
||||
if (/\bworkflow\b|whaleflow|workflow-runtime/.test(text)) labels.add('workflow-runtime');
|
||||
// `tui` means "touches the terminal UI layer", not "mentions the TUI
|
||||
// in passing". A bare \btui\b over-fired on every issue that merely
|
||||
// referenced the UI — the v0.9.2 localization issues all describe TUI
|
||||
// locale packs in prose and were labelled on that alone.
|
||||
//
|
||||
// Anchor on signals that survive the single-binary refactor (#4747):
|
||||
// the crate path and the rendering framework stay put when
|
||||
// codewhale-tui is lib-ized, whereas the `codewhale-tui` *binary*
|
||||
// name goes away. Do not reintroduce a bare word match here.
|
||||
if (/crates\/tui\b|ratatui|\bcodewhale-tui\b/.test(text)) labels.add('tui');
|
||||
if (/fleet|subagent|sub-agent/.test(text)) labels.add('subagents');
|
||||
if (/catalog|openrouter|model.?picker|provider.?lake/.test(text)) labels.add('reliability');
|
||||
|
||||
const existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const existingNames = new Set(existing.map(label => label.name));
|
||||
const currentNames = new Set((issue.labels || []).map(label => label.name));
|
||||
const toAdd = [...labels].filter(
|
||||
name => existingNames.has(name) && !currentNames.has(name)
|
||||
);
|
||||
if (toAdd.length === 0) return;
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: toAdd,
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
name: Approve gated contributor
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
# Cheap pre-filter before any token is minted: this workflow fires on
|
||||
# every issue comment, but only a maintainer's lgtm/lgtmi command does
|
||||
# anything. Expression string comparisons are case-insensitive; the
|
||||
# script below still performs the exact trimmed-command match.
|
||||
if: >-
|
||||
contains(github.event.comment.body, 'lgtm')
|
||||
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
# Job-level so skipped runs never enter the group: a workflow-level group
|
||||
# let any unrelated comment queue and cancel a pending approval run.
|
||||
concurrency:
|
||||
group: contribution-gate-approval
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Open allowlist update PR
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment;
|
||||
const issue = context.payload.issue;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
const command = (comment.body || '').trim().toLowerCase();
|
||||
const scopeByCommand = new Map([
|
||||
['/lgtm', 'pr'],
|
||||
['lgtm', 'pr'],
|
||||
['/lgtmi', 'issue'],
|
||||
['lgtmi', 'issue'],
|
||||
]);
|
||||
const scope = scopeByCommand.get(command);
|
||||
|
||||
// Answer the maintainer's command with a reaction, never a comment
|
||||
// (founder, 2026-09-22). The run log carries the detail, and the
|
||||
// allowlist PR body links back here, so the thread still shows it.
|
||||
async function react(content, message) {
|
||||
core.notice(message);
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
if (!scope) return;
|
||||
if (!privileged.has(comment.author_association)) return;
|
||||
if (scope === 'pr' && !issue.pull_request) {
|
||||
await react('confused', '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.');
|
||||
return;
|
||||
}
|
||||
if (scope === 'issue' && issue.pull_request) {
|
||||
await react('confused', '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.');
|
||||
return;
|
||||
}
|
||||
|
||||
const path = '.github/APPROVED_CONTRIBUTORS';
|
||||
const targetLogin = issue.user.login;
|
||||
const normalizedLogin = targetLogin.toLowerCase();
|
||||
const entry = `${scope}:${normalizedLogin}`;
|
||||
const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor';
|
||||
|
||||
const defaultContent = [
|
||||
'# Scoped contribution-gate allowlist.',
|
||||
'#',
|
||||
'# Maintainers and collaborators bypass the gate automatically. Use this file',
|
||||
'# for external contributors who are allowed through the automated front door.',
|
||||
'# Seed active contributors here before switching the gate workflows to enforce mode.',
|
||||
'#',
|
||||
'# Supported entries:',
|
||||
'# pr:username',
|
||||
'# issue:username',
|
||||
'# all:username',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function parseAllowlist(content) {
|
||||
return new Set(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
const { data: repoData } = await github.rest.repos.get({ owner, repo });
|
||||
const defaultBranch = repoData.default_branch;
|
||||
const { data: baseRef } = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${defaultBranch}`,
|
||||
});
|
||||
const baseSha = baseRef.object.sha;
|
||||
const { data: baseCommit } = await github.rest.git.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
commit_sha: baseSha,
|
||||
});
|
||||
|
||||
let content = defaultContent;
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
ref: defaultBranch,
|
||||
});
|
||||
if (!Array.isArray(data) && data.type === 'file') {
|
||||
content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
const existing = parseAllowlist(content);
|
||||
if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) {
|
||||
await react('eyes', `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const openPrs = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data: pagePrs } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
openPrs.push(...pagePrs);
|
||||
if (pagePrs.length < 100) break;
|
||||
}
|
||||
const repoFullName = `${owner}/${repo}`.toLowerCase();
|
||||
const pendingPr = openPrs.find(openPr => {
|
||||
const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName;
|
||||
const body = openPr.body || '';
|
||||
return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`);
|
||||
});
|
||||
|
||||
if (pendingPr) {
|
||||
await react('eyes', `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextContent = `${content.trimEnd()}\n${entry}\n`;
|
||||
const { data: blob } = await github.rest.git.createBlob({
|
||||
owner,
|
||||
repo,
|
||||
content: nextContent,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const { data: tree } = await github.rest.git.createTree({
|
||||
owner,
|
||||
repo,
|
||||
base_tree: baseCommit.tree.sha,
|
||||
tree: [
|
||||
{
|
||||
path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: blob.sha,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`;
|
||||
await github.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: baseSha,
|
||||
});
|
||||
|
||||
const { data: commit } = await github.rest.git.createCommit({
|
||||
owner,
|
||||
repo,
|
||||
message: `chore: approve @${targetLogin} for ${scope} contributions`,
|
||||
tree: tree.sha,
|
||||
parents: [baseSha],
|
||||
});
|
||||
await github.rest.git.updateRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${branchName}`,
|
||||
sha: commit.sha,
|
||||
});
|
||||
|
||||
const { data: pr } = await github.rest.pulls.create({
|
||||
owner,
|
||||
repo,
|
||||
title: `chore: approve @${targetLogin} for ${scope} contributions`,
|
||||
head: branchName,
|
||||
base: defaultBranch,
|
||||
body: [
|
||||
`Adds \`${entry}\` to \`${path}\`.`,
|
||||
'',
|
||||
`Requested by @${comment.user.login} in #${issue.number}.`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await react('rocket', `Created allowlist update PR: ${pr.html_url}`);
|
||||
@@ -0,0 +1,154 @@
|
||||
name: Auto-close harvested PRs
|
||||
|
||||
# When a commit on main contains a "Harvested from PR #N" line in its
|
||||
# message, close PR #N with a templated thank-you that links back to
|
||||
# the merged commit. Solves the long-standing problem where contributor
|
||||
# PRs whose code lands via maintainer cherry-pick stay open and
|
||||
# `CONFLICTING` forever, even though their fix is credited in the
|
||||
# CHANGELOG.
|
||||
#
|
||||
# The expected commit-message convention is documented in
|
||||
# CONTRIBUTING.md. Two patterns are recognised:
|
||||
#
|
||||
# * `Harvested from PR #1234 by @username` (preferred)
|
||||
# * `harvested from #1234` (case-insensitive fallback)
|
||||
#
|
||||
# The first match's PR number is closed; multiple PRs can be closed
|
||||
# per commit by repeating the line. The match runs on the commit
|
||||
# body only, not on the subject line, so the subject can describe
|
||||
# the change naturally without baking a number into it.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
# Only one auto-close run at a time so two near-simultaneous main
|
||||
# pushes can't both try to close the same PR (the second would just
|
||||
# fail with "Pull request is already closed", harmless but noisy).
|
||||
concurrency:
|
||||
group: auto-close-harvested
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# We need at least the commits that this push introduced.
|
||||
# fetch-depth: 0 is the simplest correct option; the
|
||||
# alternative (fetching just `before..after`) is fragile
|
||||
# when force-pushes happen.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Close PRs referenced by harvested-from lines
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
AFTER_SHA: ${{ github.event.after }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# The first push to a fresh branch has BEFORE_SHA = 0000…0000.
|
||||
# In that case fall back to the latest commit only — we don't
|
||||
# want to scan the entire history.
|
||||
if [[ "${BEFORE_SHA}" == "0000000000000000000000000000000000000000" || -z "${BEFORE_SHA:-}" ]]; then
|
||||
RANGE="${AFTER_SHA}"
|
||||
RANGE_ARGS=("-1" "${AFTER_SHA}")
|
||||
else
|
||||
RANGE="${BEFORE_SHA}..${AFTER_SHA}"
|
||||
RANGE_ARGS=("${RANGE}")
|
||||
fi
|
||||
echo "Scanning commit range: ${RANGE}"
|
||||
|
||||
# `git log --format=%H%n%B%n--END--` separates commits with a
|
||||
# sentinel so multi-line bodies don't get mangled.
|
||||
mapfile -t commits < <(git log "${RANGE_ARGS[@]}" --format="%H")
|
||||
|
||||
if [[ ${#commits[@]} -eq 0 ]]; then
|
||||
echo "No commits in range; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
declare -A processed_prs=()
|
||||
|
||||
for sha in "${commits[@]}"; do
|
||||
body="$(git log -1 --format=%B "${sha}")"
|
||||
# Two patterns, both case-insensitive on the keyword:
|
||||
# "Harvested from PR #1234 by @username" (preferred form)
|
||||
# "harvested from #1234" (short form)
|
||||
mapfile -t pr_numbers < <(
|
||||
printf '%s\n' "${body}" \
|
||||
| grep -oiE 'harvested from (pr )?#[0-9]+' \
|
||||
| grep -oE '#[0-9]+' \
|
||||
| tr -d '#' \
|
||||
| sort -u || true
|
||||
)
|
||||
|
||||
if [[ ${#pr_numbers[@]} -eq 0 ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
short_sha="${sha:0:12}"
|
||||
subject="$(git log -1 --format=%s "${sha}")"
|
||||
|
||||
for pr in "${pr_numbers[@]}"; do
|
||||
key="${pr}-${sha}"
|
||||
if [[ -n "${processed_prs[${key}]:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
processed_prs[${key}]=1
|
||||
|
||||
# Idempotency: skip if the PR is already closed.
|
||||
state="$(gh pr view "${pr}" --json state --jq .state 2>/dev/null || echo "MISSING")"
|
||||
if [[ "${state}" == "CLOSED" || "${state}" == "MERGED" ]]; then
|
||||
echo "PR #${pr} is already ${state}; skipping."
|
||||
continue
|
||||
fi
|
||||
if [[ "${state}" == "MISSING" ]]; then
|
||||
echo "::warning::PR #${pr} not found or inaccessible; skipping."
|
||||
continue
|
||||
fi
|
||||
|
||||
author="$(gh pr view "${pr}" --json author --jq '.author.login' 2>/dev/null || echo "")"
|
||||
greeting="Hi"
|
||||
if [[ -n "${author}" ]]; then
|
||||
greeting="Thanks @${author}"
|
||||
fi
|
||||
|
||||
# NOTE: this block intentionally avoids `<<EOF` heredocs.
|
||||
# YAML's `|` block scalar requires consistent indentation,
|
||||
# but heredoc bodies have to start at column 0 — those two
|
||||
# constraints can't coexist in the same file. We assemble
|
||||
# the body with `printf` + `\n` so every line of the
|
||||
# message lives at the same indent as the surrounding
|
||||
# shell code.
|
||||
commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${sha}"
|
||||
contributing_url="https://github.com/${GITHUB_REPOSITORY}/blob/main/CONTRIBUTING.md"
|
||||
body_text="$(printf '%s\n' \
|
||||
"${greeting} — your contribution landed in [\`${short_sha}\`](${commit_url}) on \`main\`:" \
|
||||
"" \
|
||||
"> ${subject}" \
|
||||
"" \
|
||||
"Closing this PR now that the code is on \`main\`. Credit lives in the commit message and (where applicable) the \`CHANGELOG.md\` entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31." \
|
||||
"" \
|
||||
"If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the [\`CONTRIBUTING.md\`](${contributing_url}) doc has a short note on what makes a contribution mergeable as-is." \
|
||||
)"
|
||||
|
||||
echo "Closing PR #${pr} (harvested in ${short_sha})"
|
||||
if ! gh pr close "${pr}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--comment "${body_text}"; then
|
||||
echo "::warning::Failed to close PR #${pr}; continuing"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "Auto-close pass complete."
|
||||
@@ -66,8 +66,8 @@ jobs:
|
||||
echo "::error::Could not parse workspace version from Cargo.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "::error::Workspace version '$v' is not valid semver (expected X.Y.Z or X.Y.Z-prerelease)" >&2
|
||||
if ! echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::Workspace version '$v' is not valid semver (expected X.Y.Z)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
name: Catalog refresh
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: catalog-refresh
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
name: Refresh the model catalog
|
||||
if: github.repository == 'codewhale-hq/Codewhale'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
BRANCH: catalog-refresh
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ vars.CODEWHALE_APP_ID }}
|
||||
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Fetch models.dev once
|
||||
run: |
|
||||
curl --fail --silent --show-error --location --max-time 60 \
|
||||
--user-agent "CodeWhale-catalog-automation (+https://github.com/codewhale-hq/Codewhale)" \
|
||||
--output "$RUNNER_TEMP/models-dev-catalog.json" \
|
||||
https://models.dev/catalog.json
|
||||
- name: Decide whether the pin is due
|
||||
id: pin
|
||||
run: |
|
||||
if python3 scripts/catalog_models_dev.py seed age --max-days 7; then
|
||||
echo "due=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "due=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Propose new models, re-lock and render
|
||||
env:
|
||||
CODEWHALE_MODELS_DEV_PATH: ${{ runner.temp }}/models-dev-catalog.json
|
||||
run: |
|
||||
python3 scripts/catalog_models_dev.py seed discover --write --snapshot "$CODEWHALE_MODELS_DEV_PATH" | tee "$RUNNER_TEMP/discover.txt"
|
||||
python3 scripts/catalog_models_dev.py seed lock > "$RUNNER_TEMP/lock-report.txt"
|
||||
python3 scripts/catalog_models_dev.py seed render
|
||||
(cd web && node scripts/derive-facts.mjs)
|
||||
- name: Decide whether anything material changed
|
||||
id: change
|
||||
run: |
|
||||
material=$(git diff -U0 -- \
|
||||
crates/config/assets/models_dev.bundled.json \
|
||||
crates/config/assets/catalog_corrections.json \
|
||||
scripts/catalog/models_dev_seed.toml \
|
||||
| grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | grep -v '"upstream":' || true)
|
||||
if [ -n "$material" ] || [ "${{ steps.pin.outputs.due }}" = "true" ]; then
|
||||
echo "open=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "open=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No catalog change and the pin is under 7 days old; nothing to propose."
|
||||
fi
|
||||
- name: Run the catalog checks
|
||||
id: checks
|
||||
if: steps.change.outputs.open == 'true'
|
||||
run: |
|
||||
set +e
|
||||
{
|
||||
python3 scripts/catalog_models_dev.py seed render --check
|
||||
python3 scripts/catalog_models_dev.py seed age --max-days 30
|
||||
python3 -m unittest scripts/catalog_models_dev_test.py
|
||||
(cd web && node scripts/check-facts.mjs)
|
||||
} > "$RUNNER_TEMP/checks.txt" 2>&1
|
||||
status=$?
|
||||
if [ "$status" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi
|
||||
tail -n 40 "$RUNNER_TEMP/checks.txt"
|
||||
- name: Push the branch and open or update the pull request
|
||||
if: steps.change.outputs.open == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CHECKS_OK: ${{ steps.checks.outputs.ok }}
|
||||
APP_ID: ${{ vars.CODEWHALE_APP_ID }}
|
||||
run: |
|
||||
git config user.name 'codewhale-agent[bot]'
|
||||
git config user.email "${APP_ID}+codewhale-agent[bot]@users.noreply.github.com"
|
||||
git checkout -B "$BRANCH"
|
||||
git add crates/config/assets/models_dev.bundled.json crates/config/assets/catalog_corrections.json \
|
||||
scripts/catalog/models_dev_seed.toml scripts/catalog/models_dev_seed.lock.json web/lib/facts.generated.ts
|
||||
git commit -m "chore(catalog): refresh the Models.dev offline seed" \
|
||||
-m "Generated by .github/workflows/catalog-refresh.yml from models.dev. No LLM wrote these rows."
|
||||
git push --force-with-lease="$BRANCH" \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/$BRANCH"
|
||||
{
|
||||
echo "Generated by the scheduled catalog refresh from models.dev. No LLM wrote these rows; review them like any other change."
|
||||
echo
|
||||
echo "New models from the vendor allowlist. Merging this PR publishes them on the public /models page; rows carry an \`unreviewed\` source until someone checks them against the vendor's docs:"
|
||||
echo
|
||||
echo '```text'
|
||||
cat "$RUNNER_TEMP/discover.txt"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Checks run in the job: \`seed render --check\`, \`seed age --max-days 30\`, \`scripts/catalog_models_dev_test.py\`, \`web/scripts/check-facts.mjs\`: $([ "$CHECKS_OK" = true ] && echo passed || echo FAILED, see the job log; this PR is a draft)."
|
||||
echo
|
||||
echo "To stop proposing a model, add its id to \`[discover].skip\` in \`scripts/catalog/models_dev_seed.toml\`."
|
||||
} > "$RUNNER_TEMP/pr-body.md"
|
||||
title="chore(catalog): refresh the Models.dev offline seed"
|
||||
existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh pr edit "$existing" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
|
||||
if [ "$CHECKS_OK" = true ]; then
|
||||
gh pr ready "$existing"
|
||||
else
|
||||
gh pr ready --undo "$existing"
|
||||
fi
|
||||
else
|
||||
draft=""
|
||||
[ "$CHECKS_OK" = true ] || draft="--draft"
|
||||
gh pr create --base main --head "$BRANCH" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md" $draft
|
||||
fi
|
||||
+44
-24
@@ -155,7 +155,7 @@ jobs:
|
||||
crates/*|docs/HOOKS.md|docs/KEYBINDINGS.md|docs/TELEMETRY.md|docs/FLEET.md|docs/FLEET_WORKFLOW_TUTORIAL.md|docs/zh_hans/FLEET.md|docs/2512.24601v2.pdf|docs/cloud-facts/*|docs/examples/*)
|
||||
heavy=true
|
||||
;;
|
||||
docs/*|*.md|packaging/aur/*|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/scripts/agent-task-metadata.test.sh|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/release/install-dogfood.sh|scripts/release/install-dogfood.test.sh|scripts/release/prepare-release.sh|scripts/release/prepare-release.test.sh|scripts/dev-cache.sh|scripts/dev-cache.test.sh|scripts/dev-cargo.sh|scripts/dev-test.sh)
|
||||
docs/*|*.md|packaging/aur/*|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/scripts/agent-task-metadata.test.sh|.github/workflows/agent-task-labels.yml|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/release/install-dogfood.sh|scripts/release/install-dogfood.test.sh|scripts/release/prepare-release.sh|scripts/release/prepare-release.test.sh|scripts/dev-cache.sh|scripts/dev-cache.test.sh|scripts/dev-cargo.sh|scripts/dev-test.sh)
|
||||
;;
|
||||
*)
|
||||
heavy=true
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
# push to main still runs it unconditionally as the pre-release
|
||||
# safety net for anything this filter misses.
|
||||
case "${path}" in
|
||||
crates/app-server/*|crates/engine/src/runtime_threads*|crates/tui/src/main.rs|scripts/mobile-smoke.sh|.github/workflows/ci.yml|Cargo.lock|Cargo.toml)
|
||||
crates/app-server/*|crates/tui/src/runtime_api*|crates/tui/src/runtime_mobile.html|crates/tui/src/runtime_threads*|crates/tui/src/main.rs|scripts/mobile-smoke.sh|.github/workflows/ci.yml|Cargo.lock|Cargo.toml)
|
||||
mobile=true
|
||||
;;
|
||||
esac
|
||||
@@ -215,11 +215,12 @@ jobs:
|
||||
# missing receipt actually matters.
|
||||
run: ./scripts/release/check-versions.sh --range-audit-advisory
|
||||
- name: Check this PR's feature release-note receipts
|
||||
# Advisory, like the range audit above: a missing CHANGELOG receipt
|
||||
# shows as a warning and maintainers add it while landing.
|
||||
# The range audit above is advisory because previous-tag..HEAD blames
|
||||
# every open PR for receipts other merges forgot. This is the same
|
||||
# check scoped to the PR's own commits, so it blocks: a `feat:` commit
|
||||
# that references #N must add #N to CHANGELOG.md in the same PR.
|
||||
# Locally: scripts/preflight.sh.
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: ./scripts/release/check-feature-release-notes.sh "${PR_BASE_SHA}" HEAD
|
||||
@@ -268,9 +269,9 @@ jobs:
|
||||
node --test scripts/release/ensure-release-assets-absent.test.js
|
||||
node --test scripts/release/verify-release-inventory.test.js
|
||||
- name: Run runtime web client tests
|
||||
# crates/app-server/tests/runtime_web_client.test.mjs exercises the embedded
|
||||
# crates/tui/tests/runtime_web_client.test.mjs exercises the embedded
|
||||
# web client's event/snapshot state machine; it ran nowhere before.
|
||||
run: node --test crates/app-server/tests/runtime_web_client.test.mjs
|
||||
run: node --test crates/tui/tests/runtime_web_client.test.mjs
|
||||
|
||||
plugin-conversion:
|
||||
name: Plugin conversion
|
||||
@@ -319,6 +320,21 @@ jobs:
|
||||
(cd "integrations/${bridge}" && npm test)
|
||||
done
|
||||
|
||||
- name: Build computer-use test desktop
|
||||
# Spawn assertions have short request deadlines; keep the cold image
|
||||
# build outside those deadlines and fail image preparation explicitly.
|
||||
timeout-minutes: 10
|
||||
run: >-
|
||||
docker build --tag codewhale-cu-linux
|
||||
--file crates/tui/plugins/computer-use/docker/Dockerfile
|
||||
crates/tui/plugins/computer-use
|
||||
|
||||
- name: Run computer-use plugin suites
|
||||
# The bundled plugin is dependency-free too; its suites cover the
|
||||
# manifest contract, the registry, the exec/ssh transport, the four
|
||||
# platform backends, and the MCP stdio protocol. No GUI input runs.
|
||||
run: (cd crates/tui/plugins/computer-use && npm test)
|
||||
|
||||
- name: Run extension host suites and check the committed bundle
|
||||
# The TypeScript extension host (experimental, [features]
|
||||
# extension_host). Tests exercise both the committed bundle and
|
||||
@@ -326,7 +342,7 @@ jobs:
|
||||
# testing; then reject any bundle drift. Node 22 matches the host floor.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd crates/engine/extension-host
|
||||
cd crates/tui/extension-host
|
||||
npm ci --ignore-scripts
|
||||
npm test
|
||||
npm run typecheck
|
||||
@@ -343,7 +359,7 @@ jobs:
|
||||
# suites only; the Rust host tests run on Node.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd crates/engine/extension-host
|
||||
cd crates/tui/extension-host
|
||||
npm run test:bun
|
||||
|
||||
extension-host-runtimes:
|
||||
@@ -358,7 +374,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: crates/engine/extension-host
|
||||
working-directory: crates/tui/extension-host
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -383,7 +399,7 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: extension-host-runtime-${{ matrix.os }}
|
||||
path: crates/engine/extension-host/dist/builtin-modules.json
|
||||
path: crates/tui/extension-host/dist/builtin-modules.json
|
||||
if-no-files-found: error
|
||||
|
||||
vscode-extension:
|
||||
@@ -421,7 +437,7 @@ jobs:
|
||||
safety-gate:
|
||||
name: Safety gate
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -469,7 +485,7 @@ jobs:
|
||||
# codewhale-runtime owns `safe_label` (its hostile-authority test)
|
||||
# since RS-2; add the next package when another safety module moves.
|
||||
run: |
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-engine -p codewhale-tui -p codewhale-runtime --lib --locked --no-tests=fail -E 'test(auto_review) | test(authority) | test(sandbox)'
|
||||
sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-tui -p codewhale-runtime --lib --locked --no-tests=fail -E 'test(auto_review) | test(authority) | test(sandbox)'
|
||||
sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-execpolicy --locked
|
||||
|
||||
lint:
|
||||
@@ -725,7 +741,7 @@ jobs:
|
||||
# fail the build there while stable stays green (#6543).
|
||||
name: MSRV check (1.89)
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -774,7 +790,6 @@ jobs:
|
||||
test:
|
||||
name: Test
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# Contexts "Test (ubuntu-latest)" / "Test (macos-latest)" /
|
||||
# "Test (windows-latest)" derive from job name + matrix.os and are
|
||||
# independent of runs-on. For light changes the macOS/Windows legs only
|
||||
@@ -896,7 +911,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
bun_binary="$(bun -p 'process.execPath')"
|
||||
host_binary="$(node -p "require('path').join(process.env.RUNNER_TEMP, 'codewhale-extension-host' + (process.platform === 'win32' ? '.exe' : ''))")"
|
||||
compiled_info="$(node crates/engine/extension-host/compile-host.mjs --bun "$bun_binary" --output "$host_binary")"
|
||||
compiled_info="$(node crates/tui/extension-host/compile-host.mjs --bun "$bun_binary" --output "$host_binary")"
|
||||
printf 'CODEWHALE_COMPILED_HOST_TEST_BINARY=%s\n' "$host_binary" >> "$GITHUB_ENV"
|
||||
node - "$host_binary" "$bun_binary" "$compiled_info" >> "$GITHUB_ENV" <<'JS'
|
||||
const fs = require('node:fs'), crypto = require('node:crypto');
|
||||
@@ -908,7 +923,7 @@ jobs:
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_SHA256=' + digest(process.argv[2]));
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_BUN=' + process.argv[3]);
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256=' + digest(process.argv[3]));
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256=' + digest('crates/engine/extension-host/dist/codewhale-extension-host.mjs'));
|
||||
console.log('CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256=' + digest('crates/tui/extension-host/dist/codewhale-extension-host.mjs'));
|
||||
JS
|
||||
# The required hermetic full invocation executes the shared actual
|
||||
# compiled Native secret/history/write scenario on each OS; Windows
|
||||
@@ -954,12 +969,12 @@ jobs:
|
||||
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
|
||||
env:
|
||||
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
|
||||
- name: Windows Terminal clipboard-to-draft acceptance
|
||||
- name: Windows Terminal clipboard acceptance
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
|
||||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: node scripts/release/windows-terminal-clipboard.mjs target/debug/codewhale.exe "${{ runner.temp }}/windows-clipboard"
|
||||
- name: Retain Windows Terminal clipboard-to-draft evidence
|
||||
- name: Retain Windows Terminal clipboard evidence
|
||||
if: ${{ !cancelled() && matrix.os == 'windows-latest' && needs.changes.outputs.heavy == 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
@@ -1149,7 +1164,7 @@ jobs:
|
||||
python3 scripts/compiled-host-native-receipt.py \
|
||||
--junit target/nextest/ci/junit.xml \
|
||||
--compiled-image "$CODEWHALE_COMPILED_HOST_TEST_BINARY" \
|
||||
--bundle crates/engine/extension-host/dist/codewhale-extension-host.mjs \
|
||||
--bundle crates/tui/extension-host/dist/codewhale-extension-host.mjs \
|
||||
--bun "$CODEWHALE_COMPILED_HOST_TEST_BUN" --source-sha "$GITHUB_SHA" \
|
||||
--image-platform "$CODEWHALE_COMPILED_HOST_TEST_PLATFORM" --image-arch "$CODEWHALE_COMPILED_HOST_TEST_ARCH" \
|
||||
--host-sha256 "$CODEWHALE_COMPILED_HOST_TEST_SHA256" \
|
||||
@@ -1282,7 +1297,7 @@ jobs:
|
||||
npm-wrapper-smoke:
|
||||
name: npm wrapper smoke
|
||||
needs: changes
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name != 'schedule'
|
||||
# Same ternary rationale as the Test job: light legs only echo, so keep
|
||||
# them off macOS/Windows runners. On pull_request the matrix is
|
||||
# ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)"
|
||||
@@ -1298,7 +1313,7 @@ jobs:
|
||||
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || github.event_name == 'workflow_dispatch' && '["ubuntu-latest","windows-latest"]' || '["ubuntu-latest","windows-latest"]') }}
|
||||
os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || github.event_name == 'workflow_dispatch' && '["ubuntu-latest","macos-latest","windows-latest"]' || '["ubuntu-latest","windows-latest"]') }}
|
||||
steps:
|
||||
- name: Skip npm wrapper smoke for light change
|
||||
if: needs.changes.outputs.heavy != 'true'
|
||||
@@ -1358,8 +1373,13 @@ jobs:
|
||||
mobile-smoke:
|
||||
name: Mobile runtime smoke
|
||||
needs: changes
|
||||
# Manual (workflow_dispatch) only; the release candidate builds android-arm64.
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
|
||||
# Not a required PR context. Pull requests run it only when the mobile
|
||||
# runtime surface changed (see the `mobile` filter above); every push to
|
||||
# main runs it unconditionally as the pre-release safety net.
|
||||
if: >-
|
||||
github.event_name != 'schedule' &&
|
||||
needs.changes.outputs.heavy == 'true' &&
|
||||
(github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true')
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1,119 +1,70 @@
|
||||
name: Codewhale PR Review
|
||||
|
||||
# Account-backed, advisory findings. Failed execution stays visibly failed;
|
||||
# do not make this optional review job a required merge check.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr-number:
|
||||
description: Pull request number
|
||||
description: Same-repository PR to review at its current revision
|
||||
required: true
|
||||
type: string
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: codewhale-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr-number }}
|
||||
cancel-in-progress: false
|
||||
group: codewhale-review-${{ github.event.pull_request.number || inputs.pr-number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
installation:
|
||||
if: >-
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
((github.event_name == 'workflow_dispatch') ||
|
||||
(github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
(github.event.issue.pull_request || github.event.pull_request) &&
|
||||
github.event.comment.user.type == 'User' && contains(github.event.comment.body, '@codewhale')))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ vars.CODEWHALE_APP_ID }}
|
||||
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
|
||||
permission-contents: read
|
||||
permission-pull-requests: write
|
||||
review:
|
||||
needs: installation
|
||||
if: >-
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
((github.event_name == 'workflow_dispatch') ||
|
||||
(github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
(github.event.issue.pull_request || github.event.pull_request) &&
|
||||
github.event.comment.user.type == 'User' && contains(github.event.comment.body, '@codewhale')))
|
||||
codewhale-review:
|
||||
name: Codewhale review
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
findings: ${{ steps.review.outputs.findings }}
|
||||
steps:
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: codewhale-hq/Codewhale@b0b2b9a335cc1d24abe8a087189bed080d1cc3db
|
||||
id: review
|
||||
with:
|
||||
version: 'v0.10.2'
|
||||
provider: 'deepseek'
|
||||
model: 'deepseek-flash'
|
||||
reasoning-effort: 'off'
|
||||
max-output-tokens: '16384'
|
||||
pr-number: ${{ inputs.pr-number }}
|
||||
trigger-phrase: '@codewhale'
|
||||
allow-forks: 'true'
|
||||
post: 'false'
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'issue_comment' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.DEEPSEEK_API_KEY || '' }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
|
||||
if: steps.review.outputs.findings != ''
|
||||
with:
|
||||
name: codewhale-findings-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.review.outputs.findings }}
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
|
||||
if: always() && steps.review.outputs.receipt != ''
|
||||
with:
|
||||
name: codewhale-review-receipt-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.review.outputs.receipt }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
publish:
|
||||
needs: review
|
||||
if: needs.review.outputs.findings != ''
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
HAS_APP_KEY: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY != '' }}
|
||||
steps:
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
|
||||
# The local action is trusted base/default-branch source. Candidate PR
|
||||
# source is fetched as Git objects by the action and is never executed.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
|
||||
with:
|
||||
name: codewhale-findings-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: findings
|
||||
- uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
|
||||
- name: Mint Codewhale Agent token
|
||||
if: env.HAS_APP_KEY == 'true' && vars.CODEWHALE_APP_ID != '' && (github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft))
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ vars.CODEWHALE_APP_ID }}
|
||||
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
|
||||
permission-contents: read
|
||||
permission-pull-requests: write
|
||||
- uses: codewhale-hq/Codewhale/scripts/github-action/publish@b0b2b9a335cc1d24abe8a087189bed080d1cc3db
|
||||
id: publish
|
||||
- name: Review with the account model
|
||||
id: review
|
||||
uses: ./
|
||||
with:
|
||||
findings: findings/findings.json
|
||||
version: ${{ vars.CODEWHALE_REVIEW_VERSION || 'v0.10.0' }}
|
||||
provider: codewhale
|
||||
model: ${{ vars.CODEWHALE_REVIEW_MODEL }}
|
||||
pr-number: ${{ inputs.pr-number }}
|
||||
trigger-phrase: '@codewhale'
|
||||
allow-forks: 'true'
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
reviewer-login: 'codewhale-agent[bot]'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
|
||||
if: always() && steps.publish.outputs.receipt != ''
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
max-chars: ${{ vars.CODEWHALE_REVIEW_MAX_CHARS || '200000' }}
|
||||
max-passes: ${{ vars.CODEWHALE_REVIEW_MAX_PASSES || '1' }}
|
||||
max-output-tokens: ${{ vars.CODEWHALE_REVIEW_MAX_OUTPUT_TOKENS }}
|
||||
env:
|
||||
CODEWHALE_API_KEY: ${{ (github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.CODEWHALE_API_KEY || '' }}
|
||||
- name: Save review outcome
|
||||
if: always() && steps.review.outputs.receipt != ''
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codewhale-publication-receipt-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.publish.outputs.receipt }}
|
||||
name: codewhale-review-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.review.outputs.receipt }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,57 @@
|
||||
name: DCO
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dco:
|
||||
name: Check Signed-off-by
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Report missing Signed-off-by (dry-run advisory)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
base_rev=$(git merge-base origin/${{ github.base_ref }} HEAD 2>/dev/null || echo "HEAD~1")
|
||||
echo "✅ merge-base: $base_rev"
|
||||
echo ""
|
||||
|
||||
bad=()
|
||||
while IFS= read -r commit; do
|
||||
msg=$(git log --format=%B -n1 "$commit")
|
||||
if ! echo "$msg" | grep -qi "^Signed-off-by:"; then
|
||||
bad+=("$commit")
|
||||
fi
|
||||
done < <(git rev-list "${base_rev}..HEAD")
|
||||
|
||||
if [ ${#bad[@]} -eq 0 ]; then
|
||||
echo "✅ All commits have Signed-off-by."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "⚠️ Advisory — the following commit(s) are missing Signed-off-by:"
|
||||
for c in "${bad[@]}"; do
|
||||
echo " • $c $(git log --format=%s -n1 "$c")"
|
||||
done
|
||||
echo ""
|
||||
echo "This check is advisory and does not block merging."
|
||||
echo "Please amend commits with:"
|
||||
echo " git commit --amend -s"
|
||||
echo "See CONTRIBUTING.md for details."
|
||||
echo ""
|
||||
echo "::warning title=DCO: missing Signed-off-by::Some commits are missing Signed-off-by — amend with git commit --amend -s"
|
||||
|
||||
# Dry-run: always pass, but surface the warning
|
||||
exit 0
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Contribution intake - issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# Labels only, never comments (founder, 2026-09-22): the intake note used
|
||||
# to thank other bots, and a comment is noise a label does not make.
|
||||
# Maintainers still see who needs triage; `/lgtmi` still skips it.
|
||||
- name: Label new external issues for triage
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
|
||||
async function label(name, color, description) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color, description });
|
||||
} catch (error) {
|
||||
if (error.status !== 422) throw error; // 422: label already exists
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
labels: [name],
|
||||
});
|
||||
}
|
||||
|
||||
if (privileged.has(issue.author_association)) return;
|
||||
// `user.type` is set by GitHub for app and bot accounts and cannot
|
||||
// be spoofed by a login that merely ends in "[bot]".
|
||||
if (issue.user.type === 'Bot') {
|
||||
await label('bot-authored', 'ededed', 'Opened by a bot or app account');
|
||||
return;
|
||||
}
|
||||
|
||||
function parseAllowlist(content) {
|
||||
return new Set(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
async function readAllowlist() {
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: '.github/APPROVED_CONTRIBUTORS',
|
||||
ref: context.payload.repository.default_branch,
|
||||
});
|
||||
if (Array.isArray(data) || data.type !== 'file') return new Set();
|
||||
return parseAllowlist(
|
||||
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.status === 404) return new Set();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const allowlist = await readAllowlist();
|
||||
const login = issue.user.login.toLowerCase();
|
||||
if (
|
||||
allowlist.has(`all:${login}`) ||
|
||||
allowlist.has(`issue:${login}`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await label(
|
||||
'needs-triage',
|
||||
'fbca04',
|
||||
'New external report awaiting maintainer triage; repro, logs and version output help'
|
||||
);
|
||||
@@ -1,70 +0,0 @@
|
||||
name: Main landing gate
|
||||
|
||||
# Why this exists
|
||||
#
|
||||
# Until 2026-11-08 `ci.yml` ran only on `pull_request` and `workflow_dispatch`,
|
||||
# so nothing validated a push to `main` and a landing that was red on arrival stayed red
|
||||
# until the next release candidate run, and every open PR then inherits the
|
||||
# failure: on 2026-10-29 four commits landed red behind `[skip ci]`
|
||||
# (`PersistRequest::ClearCheckpoint` became dead, `clippy` failed on
|
||||
# `codewhale-skills`, and the blocking-calls budget went stale for a file that
|
||||
# had moved crates) and all nine open PRs looked broken for reasons that had
|
||||
# nothing to do with their authors' code.
|
||||
#
|
||||
# This job is deliberately narrow. It runs `scripts/preflight.sh --check`,
|
||||
# which is the checked-in local preflight for exactly the class of breakage that
|
||||
# reaches `main` unnoticed: rustfmt, the CHANGELOG slice, README locale stamps,
|
||||
# the dead-code and blocking-calls budgets, generated-asset drift, and the
|
||||
# feature receipts. Measured at 45 s on a warm Linux runner.
|
||||
#
|
||||
# Since 2026-11-08 (founder) the `full-ci` job also dispatches `ci.yml` on every
|
||||
# push to main, so every gate and the three-OS suite run on each landing. The
|
||||
# preflight job stays narrow so a red landing still shows up in minutes.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: main-landing-${{ github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Landing preflight
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# The preflight diffs against the push's base commit to scope feature
|
||||
# receipts, and the changelog/budget checks read history.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt
|
||||
|
||||
- name: Preflight
|
||||
env:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: bash scripts/preflight.sh --check
|
||||
|
||||
full-ci:
|
||||
name: Dispatch full CI
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Run ci.yml with every gate on the pushed commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run ci.yml --repo "${GITHUB_REPOSITORY}" --ref main -f expected_sha="${GITHUB_SHA}"
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, re
|
||||
snapshot = json.load(open('crates/plugins/assets/first-party-marketplace.json'))
|
||||
snapshot = json.load(open('crates/tui/assets/first-party-marketplace.json'))
|
||||
revision = snapshot['revision']
|
||||
assert re.fullmatch(r'[0-9a-f]{40}', revision), 'Invalid marketplace revision'
|
||||
if os.environ['EVENT_NAME'] in ('schedule', 'workflow_dispatch'):
|
||||
@@ -45,10 +45,11 @@ jobs:
|
||||
run: node marketplace/scripts/check-marketplace.mjs
|
||||
- name: Check bundled catalog snapshot
|
||||
run: python3 core/scripts/sync-marketplace.py --marketplace marketplace --check
|
||||
- name: Check current skill mirrors
|
||||
- name: Check current skill and Computer Use mirrors
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
node marketplace/scripts/check-marketplace.mjs --core core
|
||||
node marketplace/scripts/check-cu-sync.mjs --core core
|
||||
- name: Explain a drift failure
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-java@v6.1.0
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
name: Contribution gate - pull requests
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
# Keep new gates observable first. Switch to "enforce" only after maintainers
|
||||
# have seeded active contributors and reviewed the dry-run signal.
|
||||
CONTRIBUTION_GATE_MODE: dry-run
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Gate unapproved external pull requests
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase();
|
||||
const enforceGate = gateMode === 'enforce';
|
||||
|
||||
if (!['dry-run', 'enforce'].includes(gateMode)) {
|
||||
core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`);
|
||||
}
|
||||
|
||||
async function label(name, color, description) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color, description });
|
||||
} catch (error) {
|
||||
if (error.status !== 422) throw error; // 422: label already exists
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [name],
|
||||
});
|
||||
}
|
||||
|
||||
if (privileged.has(pr.author_association)) return;
|
||||
// `user.type` is set by GitHub for app and bot accounts and cannot
|
||||
// be spoofed by a login that merely ends in "[bot]".
|
||||
if (pr.user.type === 'Bot') {
|
||||
await label('bot-authored', 'ededed', 'Opened by a bot or app account');
|
||||
return;
|
||||
}
|
||||
|
||||
function parseAllowlist(content) {
|
||||
return new Set(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
async function readAllowlist() {
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: '.github/APPROVED_CONTRIBUTORS',
|
||||
ref: context.payload.repository.default_branch,
|
||||
});
|
||||
if (Array.isArray(data) || data.type !== 'file') return new Set();
|
||||
return parseAllowlist(
|
||||
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.status === 404) return new Set();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const allowlist = await readAllowlist();
|
||||
const login = pr.user.login.toLowerCase();
|
||||
if (
|
||||
allowlist.has(`all:${login}`) ||
|
||||
allowlist.has(`pr:${login}`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Labels only (founder, 2026-09-22). The label description carries
|
||||
// the contributor-facing explanation a comment used to.
|
||||
await label(
|
||||
'contribution-gate',
|
||||
'c5def5',
|
||||
'Author not yet in .github/APPROVED_CONTRIBUTORS; a maintainer grants access with /lgtm'
|
||||
);
|
||||
|
||||
if (!enforceGate) return;
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed',
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
name: PR closes an issue
|
||||
|
||||
# 342 open issues, 329 of them touched within the month: nothing here is rotting,
|
||||
# the drain is just clogged. Only 8 of 35 open PRs carried a closing keyword, so
|
||||
# work ships and its issue stays open, and nobody can tell which of the 342 are
|
||||
# already done. That is how 121 issues end up on one milestone.
|
||||
#
|
||||
# This check asks every PR to close an issue, reference one with `Refs #N`, or
|
||||
# say why it has none (`No-Issue:`). The opt-out is one line, so this is a
|
||||
# prompt, not a wall. It also fails a negated closing keyword ("does not close
|
||||
# #N"), which GitHub would otherwise act on and close the issue.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
link:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Automated dependency bumps (dependabot and any other GitHub-verified
|
||||
# bot account) are machine-generated and can never carry a closing
|
||||
# keyword; failing them here would require hand-editing every bot body,
|
||||
# which defeats the automation. The gate stays strict for every human
|
||||
# PR. `user.type` is set by GitHub for verified bot accounts, so a PR
|
||||
# author cannot spoof it to dodge the check.
|
||||
- name: Require an issue link or an explicit opt-out
|
||||
if: github.event.pull_request.user.type != 'Bot'
|
||||
env:
|
||||
# Fetched live rather than read from the event payload. A rerun
|
||||
# replays the payload the run started with, so a body-only fix could
|
||||
# never turn this check green: the obvious operator move — add the
|
||||
# missing line, rerun the failed check — re-read the old body and
|
||||
# failed again with no hint why. Reading the current body makes a
|
||||
# rerun mean what everyone already assumes it means.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Through a variable, never interpolated into the script body:
|
||||
# a PR body is attacker-controlled text.
|
||||
PR_BODY=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json body --jq '.body // ""')
|
||||
# Body only, deliberately. GitHub resolves closing keywords from the
|
||||
# PR description; a "Closes #123" in the title auto-closes nothing.
|
||||
# Accepting the title here would pass PRs that never close an issue,
|
||||
# which is the exact false-assurance this check exists to prevent.
|
||||
text="${PR_BODY:-}"
|
||||
|
||||
# GitHub resolves a closing keyword wherever it appears, negated or
|
||||
# not: PR #6371 said "does not close #6184", GitHub put #6184 in
|
||||
# closingIssuesReferences, and the P1 closed on merge. A keyword a
|
||||
# few words after a negation is always that mistake, so fail first.
|
||||
# The reference takes all three forms GitHub closes on: #N,
|
||||
# owner/repo#N and a full issue URL.
|
||||
keyword='(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]*([[:alnum:]_.-]+/[[:alnum:]_.-]+#|#|https?://github\.com/[[:alnum:]_.-]+/[[:alnum:]_.-]+/issues/)[0-9]+'
|
||||
negation="(\\b(not|never|no longer|without)|n't)([[:space:]]+[[:alnum:]'-]+){0,3}[[:space:]]+"
|
||||
# `|| true`, not `| head`: under pipefail a SIGPIPE'd grep would turn a
|
||||
# hit into a miss.
|
||||
negated=$(grep -m1 -oiE "${negation}${keyword}" <<<"$text" || true)
|
||||
if [ -n "$negated" ]; then
|
||||
cat >&2 <<MSG
|
||||
Negated closing keyword found: "${negated}"
|
||||
|
||||
GitHub closes the issue anyway: it ignores the "not". Reword it as
|
||||
|
||||
Refs #1234 (the PR relates to the issue; nothing closes)
|
||||
|
||||
and keep Closes / Fixes / Resolves for the issue this PR really finishes.
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# GitHub's own closing-keyword set, plus the #N it must attach to.
|
||||
if grep -qiE "\\b${keyword}" <<<"$text"; then
|
||||
echo "Closing keyword found — this PR will close its issue on merge."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Opt-outs. `Refs #N` links the issue without closing it; `No-Issue:`
|
||||
# takes a one-line reason for work that has no issue at all.
|
||||
if grep -qiE '^[[:space:]]*Refs?:?[[:space:]]+#[0-9]+' <<<"$text"; then
|
||||
echo "Linked without closing — $(grep -iE '^[[:space:]]*Refs?:?[[:space:]]+#[0-9]+' <<<"$text" | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
if grep -qiE '^[[:space:]]*No-Issue:[[:space:]]*\S' <<<"$text"; then
|
||||
reason=$(grep -iE '^[[:space:]]*No-Issue:' <<<"$text" | head -1)
|
||||
echo "Opted out — ${reason}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 <<'MSG'
|
||||
This PR neither links an issue nor says why it doesn't.
|
||||
|
||||
Add one of these lines to the PR body:
|
||||
|
||||
Closes #1234 (only when this PR finishes the issue; Fixes / Resolves also close)
|
||||
Refs #1234 (related or partial work; the issue stays open)
|
||||
No-Issue: <one-line why> (chores, docs typos, revert, dependency bump)
|
||||
|
||||
Write a closing keyword only when you mean it: GitHub closes the issue
|
||||
on merge even inside "does not close #1234".
|
||||
MSG
|
||||
exit 1
|
||||
@@ -31,10 +31,6 @@ on:
|
||||
required: false
|
||||
default: 7
|
||||
type: number
|
||||
secrets:
|
||||
CODEWHALE_RELEASE_SIGNING_KEY:
|
||||
description: Ed25519 release key (PEM). Absent means the release ships unsigned.
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -91,8 +87,9 @@ jobs:
|
||||
# Both cold macOS artifact builds on 0.10.1 exhausted 90 minutes inside
|
||||
# compilation. Keep the full CI build budget; packaging stays capped below.
|
||||
timeout-minutes: 210
|
||||
# FreeBSD is a source-build target with no prebuilt asset, no npm binary and no CI check
|
||||
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd).
|
||||
# FreeBSD is a source-build target validated via `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked`
|
||||
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd). The 7×1 prebuilt matrix stays 7 targets;
|
||||
# FreeBSD has no prebuilt asset, no npm binary, and no matrix bloat — it builds from source.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -153,17 +150,6 @@ jobs:
|
||||
cli_artifact: codewhale-windows-arm64.exe
|
||||
shim_artifact: codew-windows-arm64.exe
|
||||
compat_tui_artifact: codewhale-tui-windows-arm64.exe
|
||||
# Native HarmonyOS and OpenHarmony. The artifact is useless unsigned:
|
||||
# the device refuses to execute an unsigned ELF (see
|
||||
# docs/HarmonyOS.md), so this leg self-signs the binary before staging.
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-ohos
|
||||
platform: ohos-arm64
|
||||
cli_binary: codewhale
|
||||
shim_binary: codew
|
||||
cli_artifact: codewhale-ohos-arm64
|
||||
shim_artifact: codew-ohos-arm64
|
||||
compat_tui_artifact: codewhale-tui-ohos-arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: pin
|
||||
steps:
|
||||
@@ -193,23 +179,6 @@ jobs:
|
||||
with:
|
||||
cache-bin: false
|
||||
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
|
||||
- name: Add swap so the aarch64 dist build is not killed for memory
|
||||
if: runner.os == 'Linux' && contains(matrix.target, 'aarch64')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dir=/mnt
|
||||
if ! sudo test -w /mnt; then dir=/; fi
|
||||
avail=$(df -Pk "$dir" | awk 'NR==2 {print int($4 / 1048576)}')
|
||||
if [ "$avail" -ge 30 ]; then size=16; elif [ "$avail" -ge 14 ]; then size=8; else size=0; fi
|
||||
if [ "$size" -gt 0 ]; then
|
||||
sudo fallocate -l "${size}G" "$dir/cw-swapfile"
|
||||
sudo chmod 600 "$dir/cw-swapfile"
|
||||
sudo mkswap "$dir/cw-swapfile" >/dev/null
|
||||
sudo swapon "$dir/cw-swapfile"
|
||||
fi
|
||||
free -g
|
||||
df -h "$dir"
|
||||
- name: Build static Linux binaries (musl)
|
||||
if: endsWith(matrix.target, '-unknown-linux-musl')
|
||||
shell: bash
|
||||
@@ -265,92 +234,9 @@ jobs:
|
||||
echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android24 --sysroot=${ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot"
|
||||
} >> "${GITHUB_ENV}"
|
||||
- name: Build
|
||||
if: ${{ !endsWith(matrix.target, '-unknown-linux-musl') && matrix.platform != 'ohos-arm64' }}
|
||||
if: ${{ !endsWith(matrix.target, '-unknown-linux-musl') }}
|
||||
shell: bash
|
||||
run: cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
|
||||
- name: Install OpenHarmony native SDK
|
||||
if: matrix.platform == 'ohos-arm64'
|
||||
id: ohos-sdk
|
||||
# Pin the action: it downloads a published OpenHarmony SDK and verifies
|
||||
# the archive checksum, so a broken mirror fails here instead of
|
||||
# producing a binary linked against host headers.
|
||||
uses: openharmony-rs/setup-ohos-sdk@b312caf8a43bb836aa24c08f65f97e1f09a89cad
|
||||
with:
|
||||
version: '6.1'
|
||||
# `toolchains` carries binary-sign-tool.jar, which signs the binary
|
||||
# below; a release without it would ship an ELF the device refuses to
|
||||
# execute.
|
||||
components: 'native;toolchains'
|
||||
# One download per release is cheaper than letting a tag save a 715 MB
|
||||
# cache entry that main's jobs then compete with.
|
||||
cache: false
|
||||
mirror: true
|
||||
- name: Install Java for the OpenHarmony binary signer
|
||||
if: matrix.platform == 'ohos-arm64'
|
||||
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
- name: Cross-compile and code-sign the OpenHarmony binary
|
||||
if: matrix.platform == 'ohos-arm64'
|
||||
shell: bash
|
||||
env:
|
||||
OHOS_NATIVE_SDK: ${{ steps.ohos-sdk.outputs.ohos_sdk_native }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -x "${OHOS_NATIVE_SDK}/llvm/bin/clang"
|
||||
test -d "${OHOS_NATIVE_SDK}/sysroot"
|
||||
# ohos-env.sh also puts the SDK's cmake on PATH, which aws-lc-sys
|
||||
# needs; without it the build dies inside its CMake probe.
|
||||
. ./scripts/ohos-env.sh
|
||||
cargo build --profile dist --locked --target "${{ matrix.target }}" -p codewhale-cli
|
||||
|
||||
bin="target/${{ matrix.target }}/dist/${{ matrix.cli_binary }}"
|
||||
tools="$(dirname "${OHOS_NATIVE_SDK}")/toolchains/lib"
|
||||
# SDK 6.1 publishes the native `binary-sign-tool` and no jar, while
|
||||
# DevEco ships the Java build; both take the same arguments.
|
||||
if [[ -f "${tools}/binary-sign-tool.jar" ]]; then
|
||||
signer=(java -jar "${tools}/binary-sign-tool.jar")
|
||||
elif [[ -f "${tools}/binary-sign-tool" ]]; then
|
||||
chmod +x "${tools}/binary-sign-tool"
|
||||
signer=("${tools}/binary-sign-tool")
|
||||
else
|
||||
echo "no binary-sign-tool in ${tools}; the device refuses an unsigned ELF" >&2
|
||||
ls -la "${tools}" >&2
|
||||
exit 1
|
||||
fi
|
||||
"${signer[@]}" sign -inFile "${bin}" -outFile "${bin}" -selfSign 1
|
||||
|
||||
# Capture then match. `tool | grep -q` cannot be used under
|
||||
# `pipefail`: grep exits at the first match, the tool takes SIGPIPE,
|
||||
# and llvm-readelf reports that as exit 74, which reads as a failed
|
||||
# verification of a good binary.
|
||||
signature="$("${signer[@]}" display-sign -inFile "${bin}")"
|
||||
case "${signature}" in
|
||||
*'code signature is self-sign'*) ;;
|
||||
*)
|
||||
echo 'the staged binary is not self-signed; the device would refuse to execute it' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
kind="$(file -b "${bin}")"
|
||||
case "${kind}" in
|
||||
*'ARM aarch64'*) ;;
|
||||
*)
|
||||
echo "not an aarch64 ELF: ${kind}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
headers="$("${OHOS_NATIVE_SDK}/llvm/bin/llvm-readelf" -l "${bin}")"
|
||||
case "${headers}" in
|
||||
*'/lib/ld-musl-aarch64.so.1'*) ;;
|
||||
*)
|
||||
echo "unexpected ELF interpreter for ${bin}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Materialize codew command alias
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -384,12 +270,12 @@ jobs:
|
||||
bin_dir="target/${{ matrix.target }}/dist"
|
||||
"${bin_dir}/${{ matrix.cli_binary }}" --version
|
||||
"${bin_dir}/${{ matrix.shim_binary }}" --version
|
||||
- name: Windows Terminal clipboard-to-draft acceptance
|
||||
- name: Windows Terminal clipboard acceptance
|
||||
if: matrix.platform == 'windows-x64'
|
||||
timeout-minutes: 5
|
||||
shell: pwsh
|
||||
run: node scripts/release/windows-terminal-clipboard.mjs "target/${{ matrix.target }}/dist/codewhale.exe" "${{ runner.temp }}/windows-clipboard"
|
||||
- name: Retain Windows Terminal clipboard-to-draft evidence
|
||||
- name: Retain Windows Terminal clipboard evidence
|
||||
if: ${{ !cancelled() && matrix.platform == 'windows-x64' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
@@ -640,20 +526,6 @@ jobs:
|
||||
pattern: '*'
|
||||
- name: Assemble exact authoritative release inventory
|
||||
run: node scripts/release/assemble-release-assets.js intermediate-artifacts release-assets
|
||||
- name: Sign the checksum manifest when the release key is configured
|
||||
env:
|
||||
CODEWHALE_RELEASE_SIGNING_KEY: ${{ secrets.CODEWHALE_RELEASE_SIGNING_KEY }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CODEWHALE_RELEASE_SIGNING_KEY}" ]]; then
|
||||
echo "CODEWHALE_RELEASE_SIGNING_KEY is not configured; this release is unsigned and automatic updates wait for each user's OK."
|
||||
exit 0
|
||||
fi
|
||||
bash scripts/release/sign-release-manifest.sh "${VERSION}" \
|
||||
release-assets/codewhale-artifacts-sha256.txt \
|
||||
release-assets/codewhale-artifacts-sha256.txt.sig
|
||||
node scripts/release/assemble-release-assets.js --verify release-assets
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: codewhale-release-assets
|
||||
@@ -685,7 +557,6 @@ jobs:
|
||||
node --test scripts/release/assemble-release-assets.test.js
|
||||
node --test npm/codewhale/test/artifacts.test.js npm/codewhale/test/release-assets.test.js
|
||||
- name: Render AUR metadata from candidate Linux archives
|
||||
if: ${{ !contains(inputs.version, '-') }}
|
||||
run: bash packaging/aur/render.sh release-assets "${RUNNER_TEMP}/codewhale-bin"
|
||||
- name: Smoke packed npm wrapper against candidate assets
|
||||
env:
|
||||
|
||||
@@ -137,5 +137,3 @@ jobs:
|
||||
compiled_host_run_id: ${{ vars.CODEWHALE_COMPILED_HOST_QUALIFICATION_RUN_ID || '' }}
|
||||
compiled_host_targets: ${{ vars.CODEWHALE_COMPILED_HOST_TARGETS || 'linux-x64,macos-arm64,windows-x64' }}
|
||||
retention_days: 7
|
||||
secrets:
|
||||
CODEWHALE_RELEASE_SIGNING_KEY: ${{ secrets.CODEWHALE_RELEASE_SIGNING_KEY }}
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version without v; dispatch from the matching existing vX.Y.Z or vX.Y.Z-prerelease tag ref'
|
||||
description: 'Release version, without v; dispatch from the matching existing vX.Y.Z tag ref'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -34,7 +34,6 @@ jobs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
@@ -48,8 +47,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
if ! [[ "${INPUT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release version '${INPUT_VERSION}' must use X.Y.Z or X.Y.Z-prerelease." >&2
|
||||
if ! [[ "${INPUT_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Release version '${INPUT_VERSION}' must use X.Y.Z." >&2
|
||||
exit 1
|
||||
fi
|
||||
tag="v${INPUT_VERSION}"
|
||||
@@ -61,8 +60,8 @@ jobs:
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
fi
|
||||
|
||||
if ! [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release tag '${tag}' must use vX.Y.Z or vX.Y.Z-prerelease." >&2
|
||||
if ! [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Release tag '${tag}' must use vX.Y.Z." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git rev-parse --verify "refs/tags/${tag}^{commit}" >/dev/null 2>&1; then
|
||||
@@ -77,16 +76,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${tag#v}"
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "sha=${sha}"
|
||||
echo "version=${version}"
|
||||
if [[ "${version}" == *-* ]]; then
|
||||
echo "prerelease=true"
|
||||
else
|
||||
echo "prerelease=false"
|
||||
fi
|
||||
echo "version=${tag#v}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
- name: Validate tagged release metadata
|
||||
shell: bash
|
||||
@@ -153,12 +146,10 @@ jobs:
|
||||
compiled_host_run_id: ${{ vars.CODEWHALE_COMPILED_HOST_QUALIFICATION_RUN_ID || '' }}
|
||||
compiled_host_targets: ${{ vars.CODEWHALE_COMPILED_HOST_TARGETS || 'linux-x64,macos-arm64,windows-x64' }}
|
||||
retention_days: 14
|
||||
secrets:
|
||||
CODEWHALE_RELEASE_SIGNING_KEY: ${{ secrets.CODEWHALE_RELEASE_SIGNING_KEY }}
|
||||
|
||||
docker-build:
|
||||
needs: [artifacts, resolve]
|
||||
if: ${{ !cancelled() && needs.artifacts.result == 'success' && needs.resolve.outputs.prerelease != 'true' }}
|
||||
if: ${{ !cancelled() && needs.artifacts.result == 'success' }}
|
||||
name: Docker ${{ matrix.platform }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -211,7 +202,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -289,7 +280,7 @@ jobs:
|
||||
docker:
|
||||
timeout-minutes: 30
|
||||
needs: [docker-build, release, resolve]
|
||||
if: ${{ !cancelled() && needs.docker-build.result == 'success' && needs.release.result == 'success' && needs.resolve.outputs.prerelease != 'true' }}
|
||||
if: ${{ !cancelled() && needs.docker-build.result == 'success' && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -309,7 +300,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -394,7 +385,7 @@ jobs:
|
||||
release:
|
||||
timeout-minutes: 30
|
||||
needs: [artifacts, docker-build, resolve]
|
||||
if: ${{ !cancelled() && needs.artifacts.result == 'success' && (needs.resolve.outputs.prerelease == 'true' || needs.docker-build.result == 'success') }}
|
||||
if: ${{ !cancelled() && needs.artifacts.result == 'success' && needs.docker-build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -440,7 +431,7 @@ jobs:
|
||||
name: Codewhale ${{ needs.resolve.outputs.tag }}
|
||||
files: artifacts/*
|
||||
draft: true
|
||||
prerelease: ${{ needs.resolve.outputs.prerelease == 'true' }}
|
||||
prerelease: false
|
||||
body_path: release-body.md
|
||||
overwrite_files: false
|
||||
fail_on_unmatched_files: true
|
||||
@@ -458,7 +449,7 @@ jobs:
|
||||
# also works when GITHUB_TOKEN publication suppresses release events.
|
||||
cnb:
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' && needs.resolve.outputs.prerelease != 'true' }}
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
uses: ./.github/workflows/sync-cnb.yml
|
||||
with:
|
||||
release_tag: ${{ needs.resolve.outputs.tag }}
|
||||
@@ -468,7 +459,7 @@ jobs:
|
||||
|
||||
gitcode:
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' && vars.GITCODE_MIRROR == 'enabled' && needs.resolve.outputs.prerelease != 'true' }}
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' && vars.GITCODE_MIRROR == 'enabled' }}
|
||||
uses: ./.github/workflows/sync-gitcode.yml
|
||||
with:
|
||||
release_tag: ${{ needs.resolve.outputs.tag }}
|
||||
@@ -519,19 +510,12 @@ jobs:
|
||||
# public GitHub Release and therefore needs the same read token as
|
||||
# the explicit asset gate above.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PRERELEASE}" == "true" ]]; then
|
||||
npm publish --access public --tag next
|
||||
else
|
||||
npm publish --access public
|
||||
fi
|
||||
run: npm publish --access public
|
||||
|
||||
homebrew:
|
||||
timeout-minutes: 20
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' && needs.resolve.outputs.prerelease != 'true' }}
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -590,7 +574,7 @@ jobs:
|
||||
sync-release-record:
|
||||
timeout-minutes: 15
|
||||
needs: [release, resolve]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' && needs.resolve.outputs.prerelease != 'true' }}
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -38,7 +38,8 @@ jobs:
|
||||
- { name: root, directory: . }
|
||||
- { name: web, directory: web }
|
||||
- { name: telemetry-ingest, directory: telemetry-ingest }
|
||||
- { name: extension-host, directory: crates/engine/extension-host }
|
||||
- { name: extension-host, directory: crates/tui/extension-host }
|
||||
- { name: embedded-computer-use, directory: crates/tui/plugins/computer-use }
|
||||
- { name: vscode, directory: extensions/vscode }
|
||||
- { name: feishu-bridge, directory: integrations/feishu-bridge }
|
||||
- { name: telegram-bridge, directory: integrations/telegram-bridge }
|
||||
|
||||
@@ -27,6 +27,11 @@ jobs:
|
||||
|
||||
const blob = `${issue.title || ''}\n${issue.body || ''}`;
|
||||
const patterns = [
|
||||
/\bcrypto\b/i,
|
||||
/\bairdrop\b/i,
|
||||
/\bnft\b/i,
|
||||
/\bpresale\b/i,
|
||||
/\busdt\b/i,
|
||||
/\btg\s*@/i,
|
||||
/\btelegram\s+@/i,
|
||||
/\bt\.me\//i,
|
||||
@@ -35,6 +40,8 @@ jobs:
|
||||
/\bguest\s+post/i,
|
||||
/\bbacklink/i,
|
||||
/\bbuy\s+followers/i,
|
||||
/\bjoin\s+our\s+(community|server|group)/i,
|
||||
/\bpromot[ei]\s+your\b/i,
|
||||
];
|
||||
const hit = patterns.find(p => p.test(blob));
|
||||
if (!hit) return;
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
name: Issue and PR triage
|
||||
name: Issue triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -16,95 +13,44 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Auto-label by title, body and changed paths
|
||||
- name: Auto-label by title and body
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const issue = context.payload.issue || pr;
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const text = `${title}\n${body}`;
|
||||
const labels = new Set();
|
||||
|
||||
if (pr) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const pathRules = [
|
||||
[/^crates\/(tui|tui-kit|palette)\//, 'area:tui'],
|
||||
[/^crates\/(tools\/|engine\/src\/tools\/)/, 'area:tools'],
|
||||
[/^crates\/(engine|core|runtime|agent|agent-contract|protocol)\/(?!src\/tools\/)/, 'area:core'],
|
||||
[/^crates\/mcp\//, 'area:mcp'],
|
||||
[/^crates\/(state|snapshot|memory)\//, 'area:state'],
|
||||
[/^crates\/(sandbox|execpolicy)\//, 'area:sandbox'],
|
||||
[/^crates\/(plugins|skills|skill-install|hooks)\//, 'area:plugins'],
|
||||
[/^(crates\/(app-server|app-server-protocol)|npm\/runtime-sdk)\//, 'area:runtime-api'],
|
||||
[/^crates\/models\//, 'area:providers'],
|
||||
[/^(crates\/localization|docs\/zh_hans)\//, 'area:localization'],
|
||||
[/^(npm\/(codewhale|deepseek-tui)|scripts\/release)\//, 'area:install'],
|
||||
[/^(\.github|scripts\/ci)\//, 'area:ci'],
|
||||
[/^apps\/desktop\//, 'area:desktop'],
|
||||
[/^(apps\/web|web)\//, 'area:web'],
|
||||
[/^apps\/mobile\//, 'area:mobile'],
|
||||
];
|
||||
const areas = new Set();
|
||||
let docsOnly = files.length > 0;
|
||||
for (const f of files) {
|
||||
if (!/^(docs\/|[^/]+\.md$)/.test(f.filename)) docsOnly = false;
|
||||
for (const [re, name] of pathRules) if (re.test(f.filename)) areas.add(name);
|
||||
}
|
||||
if (areas.size <= 4) areas.forEach(name => labels.add(name));
|
||||
if (docsOnly) labels.add('documentation');
|
||||
if (/^feat(\(|!|:)/.test(title)) labels.add('enhancement');
|
||||
if (/^fix(\(|!|:)/.test(title)) labels.add('bug');
|
||||
if (/^docs(\(|!|:)/.test(title)) labels.add('documentation');
|
||||
if (/^(chore|refactor|style|build)(\(|!|:)/.test(title)) labels.add('cleanup');
|
||||
if (/^perf(\(|!|:)/.test(title)) labels.add('performance');
|
||||
if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) && pr.user.type !== 'Bot') {
|
||||
labels.add('contributor:external');
|
||||
}
|
||||
} else {
|
||||
// Type — matched against the TITLE only.
|
||||
//
|
||||
// These ran against title+body and mislabelled long, well-specified
|
||||
// issues. Any body containing an acceptance criterion like "fails
|
||||
// when a pack drifts" matched the bug rule, and a "How to add a
|
||||
// locale" heading matched the question rule, so planned work landed
|
||||
// tagged `bug, question`. A title states the type; a body just
|
||||
// discusses it. `error`/`fail` are dropped entirely — they describe
|
||||
// what almost every issue talks about, not what any issue *is*.
|
||||
if (/\b(bug|crash|panic|broken|stack ?trace|regression)\b/.test(title)) labels.add('bug');
|
||||
if (/\b(feat(?:ure)?|request|enhancement|wishlist|proposal|please add|would be nice|support for)\b/.test(title)) labels.add('enhancement');
|
||||
if (/\b(docs?|readme|documentation|typo|grammar|wording|spelling)\b/.test(title)) labels.add('documentation');
|
||||
if (/\b(question|how (?:do|to)|why does|what does|is it possible)\b/.test(title)) labels.add('question');
|
||||
|
||||
// Type — matched against the TITLE only.
|
||||
//
|
||||
// These ran against title+body and mislabelled long, well-specified
|
||||
// issues. Any body containing an acceptance criterion like "fails
|
||||
// when a pack drifts" matched the bug rule, and a "How to add a
|
||||
// locale" heading matched the question rule, so planned work landed
|
||||
// tagged `bug, question`. A title states the type; a body just
|
||||
// discusses it. `error`/`fail` are dropped entirely — they describe
|
||||
// what almost every issue talks about, not what any issue *is*.
|
||||
if (/\b(bug|crash|panic|broken|stack ?trace|regression)\b/.test(title)) labels.add('bug');
|
||||
if (/\b(feat(?:ure)?|request|enhancement|wishlist|proposal|please add|would be nice|support for)\b/.test(title)) labels.add('enhancement');
|
||||
if (/\b(docs?|readme|documentation|typo|grammar|wording|spelling)\b/.test(title)) labels.add('documentation');
|
||||
if (/\b(question|how (?:do|to)|why does|what does|is it possible)\b/.test(title)) labels.add('question');
|
||||
// Locale — title contains CJK (Chinese, Japanese, Korean) characters
|
||||
if (/[-ヿ㐀-鿿가-]/.test(issue.title || '')) labels.add('lang:zh');
|
||||
|
||||
// Locale — title contains CJK (Chinese, Japanese, Korean) characters
|
||||
if (/[-ヿ㐀-鿿가-]/.test(issue.title || '')) labels.add('lang:zh');
|
||||
|
||||
// Areas (path-driven hints)
|
||||
if (/crates\/tui|\btui\b|ratatui|composer|sidebar/.test(text)) labels.add('area:tui');
|
||||
if (/crates\/core|engine|turn ?loop|agent ?loop/.test(text)) labels.add('area:core');
|
||||
if (/crates\/mcp|\bmcp\b/.test(text)) labels.add('area:mcp');
|
||||
if (/crates\/state|sqlite|sessions?|persistence/.test(text)) labels.add('area:state');
|
||||
if (/crates\/execpolicy|approval|sandbox|seatbelt|landlock/.test(text)) labels.add('area:sandbox');
|
||||
if (/crates\/tools|tool[ _]call|tool[ _]registry/.test(text)) labels.add('area:tools');
|
||||
if (/install|cargo install|npm install|scoop|homebrew|prebuilt|binary/.test(text)) labels.add('area:install');
|
||||
if (/\bdesktop app\b|electron/.test(text)) labels.add('area:desktop');
|
||||
if (/app\.codewhale\.net|\bweb app\b|codewhale web/.test(text)) labels.add('area:web');
|
||||
if (/\bmobile\b|\bios\b|\bandroid\b/.test(text)) labels.add('area:mobile');
|
||||
if (/screen ?reader|accessib|a11y|voiceover|\bnvda\b|keyboard navigation/.test(text)) labels.add('accessibility');
|
||||
if (/windows/.test(text)) labels.add('os:windows');
|
||||
if (/freebsd/.test(text)) labels.add('os:freebsd');
|
||||
if (/\bohos\b|harmonyos/.test(text)) labels.add('os:ohos');
|
||||
if (/macos|darwin|apple silicon/.test(text)) labels.add('os:macos');
|
||||
if (/\blinux\b|ubuntu|debian|fedora|arch ?linux/.test(text)) labels.add('os:linux');
|
||||
|
||||
}
|
||||
// Areas (path-driven hints)
|
||||
if (/crates\/tui|\btui\b|ratatui|composer|sidebar/.test(text)) labels.add('area:tui');
|
||||
if (/crates\/core|engine|turn ?loop|agent ?loop/.test(text)) labels.add('area:core');
|
||||
if (/crates\/mcp|\bmcp\b/.test(text)) labels.add('area:mcp');
|
||||
if (/crates\/state|sqlite|sessions?|persistence/.test(text)) labels.add('area:state');
|
||||
if (/crates\/execpolicy|approval|sandbox|seatbelt|landlock/.test(text)) labels.add('area:execpolicy');
|
||||
if (/crates\/tools|tool[ _]call|tool[ _]registry/.test(text)) labels.add('area:tools');
|
||||
if (/install|cargo install|npm install|scoop|homebrew|prebuilt|binary/.test(text)) labels.add('area:install');
|
||||
if (/windows/.test(text)) labels.add('os:windows');
|
||||
if (/macos|darwin|apple silicon/.test(text)) labels.add('os:macos');
|
||||
if (/\blinux\b|ubuntu|debian|fedora|arch ?linux/.test(text)) labels.add('os:linux');
|
||||
|
||||
if (labels.size === 0) return;
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Windows focus
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
description: Exact 40-character commit selected by --ref
|
||||
required: true
|
||||
type: string
|
||||
filter:
|
||||
description: nextest expression, for example 'test(compaction_lock) | test(persistence_actor)'
|
||||
required: true
|
||||
default: all()
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
focused:
|
||||
name: Focused tests (windows-latest)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUSTFLAGS: -Dwarnings
|
||||
RUST_MIN_STACK: 8388608
|
||||
RUST_BACKTRACE: full
|
||||
FILTER: ${{ inputs.filter }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.expected_sha }}
|
||||
fetch-depth: 1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- name: Run focused tests
|
||||
shell: bash
|
||||
run: cargo nextest run -p codewhale-engine -p codewhale-plugins -p codewhale-tui -p codewhale-app-server --lib --locked --profile ci -E "$FILTER"
|
||||
+1
-13
@@ -1,7 +1,6 @@
|
||||
# Build artifacts
|
||||
/target
|
||||
/target-*
|
||||
/vendor/codewhale-ratatui/target/
|
||||
.entire/
|
||||
.cursor/
|
||||
.plans/
|
||||
@@ -91,11 +90,9 @@ project_overhaul_prompt.md
|
||||
|
||||
# Local runtime state
|
||||
# Ignore everything under any .codewhale/ (snapshots, auto-generated
|
||||
# instructions.md, etc.) at any depth EXCEPT the committed repo authority policy
|
||||
# and this repository's own project hooks.
|
||||
# instructions.md, etc.) at any depth EXCEPT the committed repo authority policy.
|
||||
**/.codewhale/*
|
||||
!**/.codewhale/constitution.json
|
||||
!/.codewhale/hooks.toml
|
||||
.deepseek/
|
||||
**/session_*.json
|
||||
*.db
|
||||
@@ -183,12 +180,3 @@ orca-evidence/
|
||||
# Portable pet build entry points are source.
|
||||
!pet/**/*.sh
|
||||
!pet/verify.sh
|
||||
|
||||
# Private keys and certificates never belong in the tree.
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.p8
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
!docs/cloud-facts/fixtures/test-only-signing-key.pem
|
||||
@@ -1,10 +1,13 @@
|
||||
# yaml-language-server: $schema=https://aka.ms/winget-manifest.singleton.1.12.0.schema.json
|
||||
# HunterBown.CodeWhale — winget singleton manifest for CodeWhale (single binary)
|
||||
# PackageIdentifier follows the publisher convention used by the Homebrew formula (codewhale).
|
||||
# Canonical maintenance instructions live in packaging/winget/README.md; the generator mirrors these exact bytes to .winget/.
|
||||
# Winget installs only `codewhale` + `codew`; compatibility release filenames are not installed commands.
|
||||
|
||||
PackageIdentifier: HunterBown.CodeWhale
|
||||
PackageVersion: 0.10.2
|
||||
PackageLocale: en-US
|
||||
PackageVersion: 0.9.6
|
||||
DefaultLocale: en-US
|
||||
ManifestType: singleton
|
||||
ManifestVersion: 1.12.0
|
||||
ManifestVersion: 1.6.0
|
||||
|
||||
Publisher: Hmbown
|
||||
PublisherUrl: https://github.com/Hmbown
|
||||
@@ -16,7 +19,10 @@ LicenseUrl: https://github.com/codewhale-hq/CodeWhale/blob/main/LICENSE
|
||||
Copyright: Copyright (c) Hmbown
|
||||
ShortDescription: Terminal coding agent for supported hosted and local models
|
||||
Description: |
|
||||
CodeWhale runs in your terminal, connects to hosted and local models, and edits your workspace with approval controls.
|
||||
CodeWhale is a terminal coding agent that runs on your machine. The v0.9.5+ single-binary
|
||||
release ships one `codewhale` binary per target (plus the `codew` shim) across Linux x64 (musl),
|
||||
Linux arm64, Android arm64, macOS x64/arm64, and Windows x64/arm64. See https://github.com/codewhale-hq/CodeWhale
|
||||
for provider setup, Fleet workflows, and the full install guide (docs/INSTALL.md).
|
||||
Author: Hmbown
|
||||
Moniker: codewhale
|
||||
Tags:
|
||||
@@ -28,18 +34,79 @@ Tags:
|
||||
- ai
|
||||
- coding-agent
|
||||
- rust
|
||||
ReleaseNotesUrl: https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.10.2
|
||||
MinimumOSVersion: 10.0.0.0
|
||||
ReleaseNotes: https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.9.6
|
||||
ReleaseNotesUrl: https://github.com/codewhale-hq/CodeWhale/releases/tag/v0.9.6
|
||||
InstallationNotes: |
|
||||
The winget package installs the Windows binaries. For the NSIS installer (CodeWhaleSetup.exe)
|
||||
the installer adds %LOCALAPPDATA%\Programs\CodeWhale\bin to the user PATH. For the portable
|
||||
ZIP, winget extracts codewhale.exe and codew.exe side-by-side and adds the install location to PATH.
|
||||
Verify checksums with codewhale-artifacts-sha256.txt from the same GitHub Release.
|
||||
Documentations:
|
||||
- DocumentLabel: Install guide
|
||||
DocumentUrl: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md
|
||||
- DocumentLabel: Releases
|
||||
DocumentUrl: https://github.com/codewhale-hq/CodeWhale/releases
|
||||
|
||||
InstallerType: portable
|
||||
Commands:
|
||||
- codewhale
|
||||
Dependencies:
|
||||
PackageDependencies:
|
||||
- PackageIdentifier: Microsoft.VCRedist.2015+.x64
|
||||
Installers:
|
||||
# Preferred: NSIS installer for Windows x64 (per-user, no elevation, adds user PATH).
|
||||
- Architecture: x64
|
||||
InstallerUrl: https://github.com/codewhale-hq/Codewhale/releases/download/v0.10.2/codewhale-tui-windows-x64.exe
|
||||
InstallerSha256: 578280bec8d6e0ee9cfe97ee29de817535689f4078d8afd47384ff1941b1ee6c
|
||||
InstallerType: nullsoft
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/codewhale-hq/CodeWhale/releases/download/v0.9.6/CodeWhaleSetup.exe
|
||||
InstallerSha256: 040afa01a70ddae4ea7783e563bacd962a512b4d288ee5c5c5111ef2760755a7
|
||||
ProductCode: CodeWhale
|
||||
UpgradeBehavior: install
|
||||
FileExtensions:
|
||||
- toml
|
||||
ReleaseDate: 2026-08-12
|
||||
# Portable ZIP fallback — same single binary (codewhale.exe + codew.exe). Used when NSIS is blocked
|
||||
# by policy or for winget's portable install flow. NestedInstallerType is portable (no installer).
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/codewhale-hq/CodeWhale/releases/download/v0.9.6/codewhale-windows-x64.zip
|
||||
InstallerSha256: a3e0ed9b5810904ca4f0d76074aca749dfd13b71126de17bb12357d3506587b1
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-x64/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-x64/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-12
|
||||
- Architecture: x64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/codewhale-hq/CodeWhale/releases/download/v0.9.6/codewhale-windows-x64-portable.zip
|
||||
InstallerSha256: f803fba6f174ccd710e81b4e986daedcbe7a62fda0744f8c64342aa7751d1206
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-x64-portable/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-x64-portable/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-12
|
||||
- Architecture: arm64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/codewhale-hq/CodeWhale/releases/download/v0.9.6/codewhale-windows-arm64.zip
|
||||
InstallerSha256: c1ed644838550922c563fca7b00646482d3a3bb781aa92affc9059d14ca6d942
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-arm64/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-arm64/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-12
|
||||
- Architecture: arm64
|
||||
InstallerType: zip
|
||||
Scope: user
|
||||
InstallerUrl: https://github.com/codewhale-hq/CodeWhale/releases/download/v0.9.6/codewhale-windows-arm64-portable.zip
|
||||
InstallerSha256: ac2acd52090c88fd0babf94a30951b97c4eb1f45f522ca70e0fee12d2b728102
|
||||
NestedInstallerType: portable
|
||||
NestedInstallerFiles:
|
||||
- RelativeFilePath: codewhale-windows-arm64-portable/codewhale.exe
|
||||
PortableCommandAlias: codewhale
|
||||
- RelativeFilePath: codewhale-windows-arm64-portable/codew.exe
|
||||
PortableCommandAlias: codew
|
||||
ReleaseDate: 2026-08-12
|
||||
@@ -55,13 +55,10 @@ base prompt". Two more corollaries earned here:
|
||||
- Search for behavior and symbols before reviving work from an old branch. If a
|
||||
lane is obsolete, preserve its intent and evidence rather than merging stale
|
||||
code mechanically.
|
||||
- Agent-owned work lands on current `main`. Each writing agent works in its
|
||||
own short-lived git worktree cut from `origin/main`, rebases and pushes
|
||||
`main` the same day, then removes the worktree and its build directory. No
|
||||
long-lived branches, nested clones or integration lanes, and never a new
|
||||
worktree to hide dirty, stale or conflicting work: that work is still yours
|
||||
to finish. In a shared checkout, stage by explicit path and retry an
|
||||
`index.lock` failure. Verified main slices may
|
||||
- Agent-owned work uses the canonical checkout on current `main`. Do not
|
||||
create branches, worktrees, nested clones or task-specific integration lanes
|
||||
to handle dirty, stale or conflicting work. When agents share the checkout,
|
||||
partition by file and retry an `index.lock` failure. Verified main slices may
|
||||
be committed and pushed normally under the rule below; tag, release,
|
||||
deployment and destructive cleanup still require their explicit
|
||||
authorization.
|
||||
@@ -80,11 +77,11 @@ base prompt". Two more corollaries earned here:
|
||||
- Commit by explicit path; never `git add -A`.
|
||||
- Work that fails its check and cannot be fixed now is not pushed to `main`.
|
||||
Record it and report it instead.
|
||||
- **Maintainers land small verified slices directly on `main`.** Run the
|
||||
focused local check (`scripts/preflight.sh --check`), put the real counts in
|
||||
the commit message, fetch and rebase onto `origin/main` and retry on
|
||||
rejection. Never force-push or rewrite a shared ref. Contributor PRs are
|
||||
always welcome and merge as themselves (founder, 2026-11-07).
|
||||
- **Maintainers do not open PR branches** (founder, 2026-10-08: more PRs means
|
||||
more CI and slower work). Verified slices land directly on `main` and are
|
||||
pushed: run the focused gate, put the real counts in the commit message,
|
||||
fetch and rebase onto `origin/main` and retry on rejection. Never force-push
|
||||
or rewrite a shared ref. Contributor PRs still merge as PRs.
|
||||
- When the task is local-only, stay fully offline: no browsing, GitHub or remote
|
||||
Git operations, downloads, dependency installation, provider calls, or
|
||||
source/diff transmission. Record the missing external receipt and keep working
|
||||
@@ -105,19 +102,19 @@ base prompt". Two more corollaries earned here:
|
||||
- **Write down what a design does not do**, beside the behaviour it owns — a
|
||||
short known-limitations note in the owning module. A stated limit stops the
|
||||
next reader from assuming a capability that was never built.
|
||||
- **Be generous in public** (founder, 2026-11-07). Thank people specifically
|
||||
for their reports, diagnosis, fixes and testing; link the carrying commit or
|
||||
PR and distinguish candidate work from a shipped release. Status, answers
|
||||
and useful questions are welcome; repetitive progress posts and bot noise
|
||||
are not. Evidence stays in commits and PR bodies; ownership stays in Linear.
|
||||
A contributor PR is never closed because CI is slow, flaky or red for a
|
||||
reason the PR did not cause; when a PR is closed, say why in one specific,
|
||||
thankful sentence. Review bots stay off unless the founder enables them.
|
||||
- **Substantive contributor replies are authorized for the 0.10.2 release**
|
||||
(founder, 2026-10-13). Thank people specifically for their reports, diagnosis,
|
||||
fixes and testing; link the carrying commit or PR and distinguish candidate
|
||||
work from a shipped release. Status, answers and useful questions are welcome;
|
||||
repetitive progress posts and bot noise are not. Evidence stays in commits
|
||||
and PR bodies; ownership stays in Linear. Outside this release, the previous
|
||||
no-comment rule remains, except a one-sentence reason when closing or
|
||||
superseding a human contribution. Review workflows remain disabled unless
|
||||
the founder separately authorizes enabling them.
|
||||
- **A user feature lands with its registry row.** A new command, `[features]`
|
||||
flag, provider or user-visible feature adds or updates its row in
|
||||
`docs/features.toml` in the same change;
|
||||
`cargo test -p codewhale-tui --test feature_registry` fails on flag drift.
|
||||
- **Public planned-work issues are written for contributors.** An issue opened for planned or release work has the parts in CONTRIBUTING.md § "What a planned-work issue gives you": status, ways to help with files, a local test command and a done-when, what maintainers already hold, and how to claim. Keep it current as pieces land or are claimed.
|
||||
- **Write `close`/`fix`/`resolve #N` only when you mean it.** GitHub closes the
|
||||
issue on merge even inside "does not close #N"; use `Refs #N` otherwise.
|
||||
|
||||
@@ -126,18 +123,17 @@ base prompt". Two more corollaries earned here:
|
||||
An external contributor's branch goes stale because *we* land things, not
|
||||
because they did anything wrong. Treat their time as more expensive than ours.
|
||||
|
||||
**The goal is the contributor's PR merging as itself.** Review it, run or
|
||||
rerun only the checks it needs, fix small problems on their branch, and resolve
|
||||
conflicts that `main` caused. Landing their commits as ours (harvesting) is the
|
||||
rare fallback for a branch we cannot push to and that cannot merge in
|
||||
reasonable time; ask on the PR first, because done casually it reads as taking
|
||||
the work even when credit is preserved.
|
||||
**The goal is the contributor's PR merging as itself.** Review it, help it
|
||||
rebase, or fix it on their branch — that is the default path. Closing their PR
|
||||
and re-landing the work as our own commit (`auto-close-harvested`) is the
|
||||
fallback for a branch that truly cannot merge in reasonable time; done
|
||||
casually it reads as taking the work even when credit is preserved.
|
||||
|
||||
- **Never make a contributor rebase around our churn.** If their PR conflicts
|
||||
only because main moved, a maintainer resolves it.
|
||||
- Landing mechanics — merge-base diffing, mid-function conflicts, fork-push
|
||||
refusal, runs awaiting first-contributor approval — live in `cw-land`.
|
||||
Follow them instead of improvising.
|
||||
refusal, the contribution gate — live in `cw-land`. Follow them instead of
|
||||
improvising.
|
||||
- **Preserve credit in the mechanical sense, not just the polite one.** Commit
|
||||
authorship and `Co-authored-by` trailers must use the contributor's own
|
||||
GitHub-linked address. `AUTHOR_MAP` and `.mailmap` are project conventions —
|
||||
@@ -173,11 +169,11 @@ the work even when credit is preserved.
|
||||
- The model-facing subagent tool is `agent`; `agent_open`/`agent_eval`/
|
||||
`agent_close`/`delegate_to_agent` are removed surfaces. If the shape must
|
||||
move, move the code and its existing guard test with it.
|
||||
- `BASE_PROMPT` in `crates/engine/src/prompts/text.rs` is the sole base prompt
|
||||
- `BASE_PROMPT` in `crates/tui/src/prompts/text.rs` is the sole base prompt
|
||||
by convention. Same rule: move the code, not the prose, if that changes.
|
||||
- There is exactly one turn loop: `Engine::run_turn` in
|
||||
`crates/engine/src/core/engine/turn_loop.rs`. Note that `crates/engine/src/core/`
|
||||
is a module inside the engine crate (`codewhale-engine`) — it is not `crates/core`, which owns
|
||||
`crates/tui/src/core/engine/turn_loop.rs`. Note that `crates/tui/src/core/`
|
||||
is a module inside the TUI crate — it is not `crates/core`, which owns
|
||||
request construction, bounded fragments, and thread/session types and
|
||||
runs no turns. A guard test (`crates/core/tests/single_turn_loop.rs`)
|
||||
fails on a second loop; changing the shape means changing the guard
|
||||
@@ -187,8 +183,8 @@ the work even when credit is preserved.
|
||||
KV-cache effect: frozen prefix vs. append-only history. Never splice a
|
||||
volatile fact into the prefix; append it as a user-role message.
|
||||
- These active modules are repeatedly misidentified as dead; verify consumers
|
||||
before removal: `runtime/src/context_budget.rs`,
|
||||
`runtime/src/prompt_zones.rs`, `engine/src/tools/remember.rs`, and
|
||||
before removal: `runtime/src/context_budget.rs`, `tui/src/model_registry.rs`,
|
||||
`runtime/src/prompt_zones.rs`, `tui/src/tools/remember.rs`, and
|
||||
`config/src/route/`. Native memory lives in `runtime/src/native_memory.rs`;
|
||||
`tools/remember.rs` is its capture path.
|
||||
- Environment-specific behavior belongs in `docs/ENVIRONMENTS.md`, not here.
|
||||
@@ -205,10 +201,10 @@ the work even when credit is preserved.
|
||||
- Product intent and observed runtime behavior outrank a test's preferred
|
||||
implementation shape. Fix the product; do not contort production code to
|
||||
preserve a brittle assertion.
|
||||
- **A regression test per fix; no new code comments.** Prove a change by
|
||||
running it and the existing checks. A bug fix, or a new contract between
|
||||
components, may add the one test that would have caught it; no coverage for
|
||||
its own sake. Never write tests first and never practice TDD — this
|
||||
- **No new tests and no new code comments unless explicitly asked**
|
||||
(founder, 2026-10-08). Do not write new unit or integration tests unless the
|
||||
user explicitly instructs it for that task; prove a change by running it and
|
||||
the existing checks. Never write tests first and never practice TDD — this
|
||||
overrides any skill or default that mandates it, including superpowers
|
||||
`test-driven-development`. Existing tests stay the gate before a push: when
|
||||
a change makes one wrong, update or delete it in the same slice rather than
|
||||
@@ -262,11 +258,8 @@ lands as our commit, the contributor is that commit's **author** (`git commit
|
||||
list counts commit authors only: about 200 harvested commits authored by
|
||||
maintainer identities left their contributors off it, which is why
|
||||
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
|
||||
A maintainer closes their PR by hand with a thank-you naming the commit;
|
||||
nothing closes a PR automatically. Canonical human identities come from
|
||||
`.github/AUTHOR_MAP`.
|
||||
`auto-close-harvested.yml` closes their PR with credit. Canonical human
|
||||
identities come from `.github/AUTHOR_MAP`.
|
||||
|
||||
Leave unrelated work intact. No automation closes, blocks or labels a
|
||||
contributor as unapproved, and no workflow checks out or runs untrusted PR code
|
||||
under `pull_request_target`. Keep required PR checks few and fast; exhaustive
|
||||
validation belongs to the release candidate.
|
||||
Leave unrelated work intact and keep new enforcement dry-run unless explicitly
|
||||
approved.
|
||||
-1044
File diff suppressed because it is too large.
Load diff
+183
-180
@@ -1,103 +1,6 @@
|
||||
# Contributing to Codewhale
|
||||
# Contributing to codewhale
|
||||
|
||||
Codewhale is what it is because of the people who used it, reported what broke,
|
||||
translated it, added their providers and sent fixes. Thank you for being here.
|
||||
Everyone is welcome, in English or 中文, with or without code.
|
||||
|
||||
All feedback is a gift to this project, including "this looked wrong" or "I got
|
||||
lost here" with no fix attached. The [roadmap](docs/ROADMAP.md) says where
|
||||
Codewhale is going and where help counts most.
|
||||
|
||||
## Where things go
|
||||
|
||||
| You have | Best place |
|
||||
| --- | --- |
|
||||
| A bug, a crash, or behavior that surprised you | [Bug report](https://github.com/codewhale-hq/CodeWhale/issues/new?template=bug_report.yml) |
|
||||
| An idea or a feature you want | [Feature request](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml), or [Ideas](https://github.com/codewhale-hq/CodeWhale/discussions/categories/ideas) to talk it through first |
|
||||
| Feedback on how something looks, reads or feels | [Feedback](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feedback.yml) |
|
||||
| A question | [Q&A](https://github.com/codewhale-hq/CodeWhale/discussions/categories/q-a) or [Discord](https://discord.gg/37gfS3ksug) |
|
||||
| A security problem | A [private advisory](https://github.com/codewhale-hq/CodeWhale/security/advisories/new), never a public issue ([policy](.github/SECURITY.md)) |
|
||||
| A fix for an open issue | A pull request that links the issue |
|
||||
| A translation, a provider or model, a docs fix | A pull request, no issue needed ([standing projects](#standing-projects)) |
|
||||
| A new feature, already written or not | An issue first ([why](#new-features-start-as-an-issue)) |
|
||||
|
||||
If you are not sure, open an issue. Nothing is filed in the wrong place for
|
||||
long: a maintainer moves it.
|
||||
|
||||
## New features start as an issue
|
||||
|
||||
Issues are the best place for feature requests, ideas and bugs. A pull request
|
||||
is the best place for work we have already agreed on.
|
||||
|
||||
A feature that first appears as a pull request takes longer to land than one
|
||||
that starts as an issue, even when we want it. The questions that decide a
|
||||
feature are about the product, not the diff: does it belong in the engine or in
|
||||
a plugin, what does it do in each permission mode, how does it show up in
|
||||
receipts, what is it called in 19 languages. Answering those in a review means
|
||||
rewriting code you already finished. Answering them in an issue costs you a few
|
||||
sentences.
|
||||
|
||||
So for a new feature:
|
||||
|
||||
1. Open a [feature request](https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml).
|
||||
Say what you are trying to do and what gets in the way. A proposed design is
|
||||
welcome and optional.
|
||||
2. Tick "I would like to open a pull request" if you want to build it.
|
||||
3. A maintainer answers with the shape that fits, or with what is still
|
||||
undecided. Then the pull request is small and merges quickly.
|
||||
|
||||
A feature pull request that arrives without an issue is still read, and it is
|
||||
never closed for that reason. We will most likely ask the same questions there
|
||||
and it will take longer.
|
||||
|
||||
## Standing projects
|
||||
|
||||
These are always open, and a pull request needs no issue first:
|
||||
|
||||
- **Translations.** You can improve the terminal language packs, the translated
|
||||
READMEs in `docs/i18n/`, the website, and the translated docs in
|
||||
`docs/zh_hans/` and `docs/id/`. The [localization matrix](docs/LOCALIZATION.md)
|
||||
shows what each language has and how to add one, and the
|
||||
[Chinese localization group](https://github.com/codewhale-hq/CodeWhale/issues/6804)
|
||||
is recruiting.
|
||||
- **Providers and models.** You can add a missing provider or a new model, or
|
||||
correct a price, a context size or a provider quirk.
|
||||
[Providers](docs/PROVIDERS.md) lists what ships, and
|
||||
`python3 scripts/check-provider-registry.py` checks your change.
|
||||
- **Documentation.** You can fix a typo, an unclear step, a missing example or
|
||||
an instruction that no longer matches the product.
|
||||
- **Bug fixes.** If there is no issue yet, describe the bug in the pull request
|
||||
by saying what you ran, what happened and what you expected.
|
||||
- **Planned work.** You can take anything in a
|
||||
[milestone](docs/ROADMAP.md#releases) or anything labelled
|
||||
[help wanted](https://github.com/codewhale-hq/CodeWhale/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).
|
||||
Comment on the issue to claim it so two people do not build the same thing.
|
||||
|
||||
Large changes to planned work are easier in layers; see
|
||||
[Layered and EPIC-Sized Work](#layered-and-epic-sized-work).
|
||||
|
||||
### What a planned-work issue gives you
|
||||
|
||||
Every issue a maintainer opens for planned work, and every release tracking issue, has the same parts, so you can pick one up without asking first:
|
||||
|
||||
- **Status:** what is on `main` already, with commit links, and what is still missing.
|
||||
- **Ways to help:** each piece open to contributors, with the files to read, the command to test it locally (usually `scripts/dev-test.sh <crate> <filter>`) and what "done" means. Pieces that need no Rust, such as trying a feature and reporting exactly what happened, are listed first. A precise report is credited like code.
|
||||
- **Being built by maintainers right now:** the parts someone already holds, so you don't build the same thing twice.
|
||||
- **How to take a piece:** comment to claim it, then open a pull request that says `Refs #<issue>`.
|
||||
|
||||
If an issue for planned work is missing one of these, say so on it. That is a bug in the issue.
|
||||
|
||||
## How we work with you
|
||||
|
||||
- We reply by naming what you reported, diagnosed or fixed.
|
||||
- Your pull request merges as itself, with you as the author
|
||||
([details](#how-your-contribution-lands)).
|
||||
- You do not rebase around our changes, and a red check you did not cause is
|
||||
ours to fix.
|
||||
- We credit you in the changelog and in
|
||||
[the contributors list](docs/CONTRIBUTORS.md).
|
||||
- If something is not ready or not a fit, we say what is blocked and what would
|
||||
change the answer.
|
||||
Thank you for your interest in contributing to codewhale! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -326,9 +229,9 @@ Co-author a *person* freely; the address must be their GitHub-linked one
|
||||
|
||||
When a commit harvests code from a community PR (see "How Your Contribution
|
||||
Lands" below), include a `Harvested from PR #N by @author` line in the commit
|
||||
body. A maintainer then closes the referenced PR by hand with a thank-you and
|
||||
a link to the commit, so the contributor gets a clear signal that their work
|
||||
shipped.
|
||||
body. An auto-close workflow watches for this pattern and closes the
|
||||
referenced PR with credit so the contributor gets a clear signal that
|
||||
their work shipped.
|
||||
|
||||
## What happens after you open a pull request
|
||||
|
||||
@@ -339,23 +242,26 @@ maintainer to approve the first run for a brand-new GitHub account.
|
||||
| Check | What it runs | When it is red |
|
||||
| --- | --- | --- |
|
||||
| `Lint` (required) | `cargo fmt --check`, clippy on every target, and the fast drift checks (locales, READMEs, provider registry, dead-code and blocking-call budgets) | Run `cargo fmt --all` and the clippy command in [Pre-push verification](#pre-push-verification); the log names the file and the check |
|
||||
| `Test (ubuntu-latest)` | The whole workspace suite on Linux with `cargo nextest`, plus doctests | Re-run the named test locally, for example `scripts/dev-test.sh tui <test name>` |
|
||||
| `Test (windows-latest)` (required) | Compiles every workspace target for Windows with warnings denied, and the installer PATH helper tests | Usually a `cfg(windows)` path or an import only Windows sees; the log has the compiler error |
|
||||
| `npm wrapper smoke (ubuntu-latest)` (required) | Builds `codewhale` and installs it through the npm wrapper | The log shows the failing install or entrypoint step |
|
||||
| `Safety gate` | The sandbox, authorization and auto-review tests | Treat it as a real regression in a trust boundary |
|
||||
| `Version drift` | Version files, the `feat:` release-note rule, release helper tests and contributor credit | The credit step only reports: a maintainer adds your credit when the PR merges |
|
||||
| `PR closes an issue` | Your description has `Closes #N`, `Refs #N` or `No-Issue: <reason>` | Edit the description, then re-run the check |
|
||||
| `DCO` | Lists commits without `Signed-off-by` | Advisory; it never blocks |
|
||||
| `Contribution gate` | Labels PRs from authors not yet in `.github/APPROVED_CONTRIBUTORS` | A label, not a judgement; see [Contribution Gate](#contribution-gate) |
|
||||
|
||||
Smaller checks cover the VS Code extension, integrations, the extension host
|
||||
and plugin conversion, plus workflow lint when you change `.github/workflows`.
|
||||
A documentation-only change skips the Rust builds but still reports the
|
||||
required checks, so it can merge.
|
||||
Smaller checks cover MSRV, the VS Code extension, integrations, the extension
|
||||
host and plugin conversion, plus workflow lint when you change
|
||||
`.github/workflows`. A documentation-only change skips the Rust builds but
|
||||
still reports the required checks, so it can merge. The Linux test suite took
|
||||
45 minutes on a recent pull request; the other checks run alongside it.
|
||||
|
||||
Your pull request does not run the test suites, the safety gate, MSRV, the npm
|
||||
wrapper smoke, the slow runtime-contract and persistence-backlog measurements,
|
||||
the dependency audits or the OpenHarmony build. Maintainers run those once per
|
||||
release, on the exact commit being released. If that run finds a problem your
|
||||
change introduced, we fix it on `main`; we will not ask you to chase it.
|
||||
|
||||
A red check that your change did not cause is not your job to fix. We rerun it
|
||||
or fix it on `main`, and we never close a PR because CI is slow or flaky. Your
|
||||
PR may be a draft, and the issue line in its description is optional.
|
||||
Your pull request does not run the macOS and Windows test suites, the slow
|
||||
runtime-contract and persistence-backlog measurements, the dependency audits
|
||||
or the OpenHarmony build. Maintainers run those once per release, on the exact
|
||||
commit being released. If that run finds a problem your change introduced, we
|
||||
fix it on `main`; we will not ask you to chase it.
|
||||
|
||||
## How Your Contribution Lands
|
||||
|
||||
@@ -374,10 +280,9 @@ Before merging, a maintainer may:
|
||||
### Fallback — harvest
|
||||
|
||||
Rarely, a branch cannot merge in reasonable time, for example when it is in a
|
||||
fork we cannot push to and the conflicts cannot be resolved there. We will ask
|
||||
on your PR first. If it still cannot merge, a maintainer may **harvest** the
|
||||
useful commits into `main` instead. This is **not a rejection**: your code
|
||||
landed, and your credit is kept mechanically.
|
||||
fork we cannot push to and the conflicts cannot be resolved there. Then a
|
||||
maintainer may **harvest** the useful commits into `main` instead. This is
|
||||
**not a rejection**: your code landed, and your credit is kept mechanically.
|
||||
|
||||
- You stay the author of the harvested commit, with your GitHub-linked address
|
||||
from `.github/AUTHOR_MAP` or
|
||||
@@ -386,8 +291,8 @@ landed, and your credit is kept mechanically.
|
||||
- The commit body includes `Harvested from PR #N by @your-handle`. That line is
|
||||
the contract: it is your credit and the signal that your work shipped.
|
||||
- The `CHANGELOG.md` entry for the next release credits you by handle.
|
||||
- A maintainer closes your PR by hand with a thank-you and a link to the commit
|
||||
on `main`.
|
||||
- `auto-close-harvested.yml` closes your PR with a thank-you and a link to the
|
||||
commit on `main`.
|
||||
|
||||
When a maintainer closes a harvested PR by hand, the closing comment
|
||||
follows this template (the pattern set on PR #2634):
|
||||
@@ -477,54 +382,96 @@ because `main` moved while it was in review, say so and a maintainer resolves
|
||||
it on your branch. If your branch is in a fork we cannot push to, we ask on the
|
||||
PR first; harvesting is the last resort.
|
||||
|
||||
## Labels
|
||||
## Contribution Gate
|
||||
|
||||
Issues and pull requests are labelled by `.github/workflows/triage.yml`, which
|
||||
only ever adds labels that already exist. Pull requests get their labels from
|
||||
the paths they change and their conventional-commit title prefix; issues get
|
||||
them from the title and form answers.
|
||||
Codewhale uses a maintainer-managed contribution gate for the community front
|
||||
door. Maintainers and collaborators bypass this gate automatically. The gate
|
||||
workflows run in dry-run mode and only add labels, so maintainers can observe
|
||||
the signal before changing contributor flow.
|
||||
|
||||
| Family | Meaning |
|
||||
| --- | --- |
|
||||
| `area:*` | The subsystem or client: `tui`, `core`, `tools`, `mcp`, `state`, `sandbox`, `plugins`, `providers`, `runtime-api`, `install`, `localization`, `ci`, `desktop`, `web`, `mobile`, `remote-control`, `subagents` |
|
||||
| `os:*` | Platform-specific behaviour: `windows`, `macos`, `linux`, `freebsd`, `ohos` |
|
||||
| `priority:p0` / `p1` / `p2` | Set by a maintainer; `release-blocker` still marks work that must land before a release |
|
||||
| `bug`, `enhancement`, `documentation`, `question`, `cleanup`, `performance`, `security` | What kind of change it is |
|
||||
| `accessibility` | Screen readers, keyboard use, contrast, motion |
|
||||
| `contributor:external` | Opened by someone outside the maintainer team |
|
||||
| `lang:zh` | Written in Chinese |
|
||||
| `needs-triage`, `needs-info`, `agent-ready`, `agent-in-progress`, `needs-human`, `keep-open`, `pinned`, `stale` | Workflow state; other workflows read these names |
|
||||
The maintainer posture is documented in
|
||||
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): automation should reduce load while
|
||||
keeping good-faith contributors seen, credited, and able to keep helping.
|
||||
|
||||
Release targeting uses milestones, not version labels.
|
||||
Issues are never auto-closed by the contribution gate. Unapproved external
|
||||
issues receive a triage label and remain open for maintainers. Codewhale
|
||||
depends on real edge cases from real users, so issue intake should stay warm
|
||||
and open.
|
||||
|
||||
## Working with an agent
|
||||
Pull requests are different because they can touch code, CI, release plumbing,
|
||||
auth, sandboxing, provider policy, and other trust-boundary surfaces. The PR
|
||||
gate can be switched from dry-run to enforcement when maintainers decide they
|
||||
need that safety control, but it should be treated as a review-load control,
|
||||
not a judgment on contributor quality. Before enabling PR enforcement, seed the
|
||||
allowlist broadly enough for active external contributors who should not be
|
||||
interrupted by the rollout.
|
||||
|
||||
Using an AI assistant is welcome and needs no disclosure. Codewhale is used to
|
||||
work on Codewhale, and many fixes here began as a person pointing an agent at
|
||||
an issue.
|
||||
The allowlist is scoped:
|
||||
|
||||
What makes agent-assisted work easy to merge is the same as for any other: a
|
||||
specific reproduction, a small diff, the checks you ran, and a description of
|
||||
the trade-off. Keep each pull request to one friction point, and put a list of
|
||||
ideas for improvement in an issue.
|
||||
- `pr:username` allows pull requests.
|
||||
- `issue:username` allows issues.
|
||||
- `all:username` allows both.
|
||||
|
||||
Do not point an agent at auth, credentials, sandbox policy, publishing and
|
||||
release plumbing, provider policy, telemetry, sponsorship, branding or global
|
||||
prompts without a maintainer's agreement on the issue first.
|
||||
A maintainer can approve someone by commenting `/lgtm` on a pull request for PR
|
||||
access, or `/lgtmi` on an issue for issue access. The exact bare commands
|
||||
`lgtm` and `lgtmi` are also accepted for compatibility, but the prefixed forms
|
||||
are preferred because they are harder to trigger accidentally in ordinary review
|
||||
discussion.
|
||||
|
||||
Maintainers write some issues with the
|
||||
[Agent task](.github/ISSUE_TEMPLATE/agent-task.yml) form so that an agent with
|
||||
no other context can finish them; those carry the `agent-ready` label and are
|
||||
open to anyone. Agents and maintainers follow
|
||||
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md).
|
||||
Approvals do not edit `main` directly. The approval workflow opens a small
|
||||
allowlist update PR so the new entry is reviewable before it takes effect.
|
||||
|
||||
## How maintainers ship
|
||||
If the PR gate fires on a good contributor incorrectly, use the same approval
|
||||
flow to restore them: comment `/lgtm`, merge the generated allowlist PR, then
|
||||
reopen the affected pull request. If GitHub will not allow the closed PR to be
|
||||
reopened, ask the contributor to resubmit after the allowlist PR is merged.
|
||||
|
||||
Maintainers, and the agents working for them, land small verified slices
|
||||
directly on `main` with the checks they ran in the commit message. Every push
|
||||
to `main` runs a fast preflight and the full CI suite on Linux, macOS and
|
||||
Windows. A release also needs a green `release-candidate.yml` run on the exact
|
||||
commit being released; the sequence is in
|
||||
## Agent-Assisted Improvements
|
||||
|
||||
Codewhale is allowed to help improve Codewhale, but the contribution still has
|
||||
to be shaped for human review. The recommended workflow is the recursive self-improvement prompt
|
||||
in the private `codewhale-ops` repo: run it
|
||||
from a fresh fork or branch, let the agent find exactly one small friction point,
|
||||
and stop after one patch. DeepSeek V4 Pro is the reference path for this loop
|
||||
today, but any configured provider works — the review shape matters more than
|
||||
the provider.
|
||||
|
||||
Agents and maintainers should follow the stewardship posture in
|
||||
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): use automation for evidence,
|
||||
verification, and narrow patches while keeping the final community decision
|
||||
human-reviewed.
|
||||
|
||||
The useful output is not "ideas for improvement." The useful output is a
|
||||
specific reproduction, a minimal diff, focused checks, and a PR description that
|
||||
explains the trade-off. Do not use an agent to touch auth, credentials, sandbox
|
||||
policy, publishing/release plumbing, provider policy, telemetry, sponsorship,
|
||||
branding, or global prompts without prior maintainer sign-off.
|
||||
|
||||
## How maintainers and agents ship
|
||||
|
||||
Maintainers, and the agents working for them, do not open pull requests. They
|
||||
land verified work directly on `main` with the local checks they ran and their
|
||||
counts in the commit message. A push to `main` starts no GitHub build or test
|
||||
workflow: GitHub only mirrors `main` to CNB and GitCode and closes harvested
|
||||
PRs. CNB then runs its own Linux gates on the mirror and reports them on CNB,
|
||||
not on GitHub. Do not write `[skip ci]`; nothing needs skipping, and it would
|
||||
also skip the mirrors and the harvest auto-close.
|
||||
|
||||
The exhaustive gate runs once, at the final release step, from one dispatch on
|
||||
the frozen candidate commit:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
candidate_sha="$(git rev-parse origin/main)"
|
||||
gh workflow run ci.yml --repo codewhale-hq/CodeWhale --ref main \
|
||||
-f expected_sha="${candidate_sha}"
|
||||
```
|
||||
|
||||
It stops if `main` no longer resolves to that commit. Otherwise it runs every
|
||||
CI gate on Linux, macOS and Windows, the budget measurements, the
|
||||
documentation build, `cargo-deny`, the cargo and npm audits and the
|
||||
OpenHarmony build. Publishing also needs a green `release-candidate.yml` run
|
||||
on the same commit, which `release.yml` checks; the full sequence is in
|
||||
[docs/RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md).
|
||||
|
||||
Contributor pull requests still merge as pull requests, as described in
|
||||
@@ -533,7 +480,7 @@ Contributor pull requests still merge as pull requests, as described in
|
||||
## Project Structure
|
||||
|
||||
Codewhale is a Cargo workspace with one Engine implementation in
|
||||
`crates/engine/src/core/engine/`. The public `codewhale` executable links the
|
||||
`crates/tui/src/core/engine/`. The public `codewhale` executable links the
|
||||
TUI/runtime library; interactive sessions, noninteractive runs and the Runtime
|
||||
API share that Engine.
|
||||
|
||||
@@ -567,37 +514,93 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the runtime data flow and
|
||||
[the build guide](docs/BUILD_PERFORMANCE.md) for crate dependencies and local
|
||||
verification.
|
||||
|
||||
## Writing the pull request
|
||||
## Submitting Changes
|
||||
|
||||
- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md): what and
|
||||
why, where it came from, and how you tested it.
|
||||
- Link the issue: `Closes #N` (or `Fixes` / `Resolves`) when the pull request
|
||||
finishes it, `Refs #N` for related or partial work. Never write a closing
|
||||
keyword inside a negation such as "does not close #N": GitHub closes the
|
||||
issue anyway.
|
||||
- Keep it to one change. If you find related work, open an issue for it.
|
||||
- If you add a layer, module or abstraction, say which one it replaces.
|
||||
- Update the docs your change makes wrong.
|
||||
- A draft is fine, and so is asking for direction before it is finished.
|
||||
1. Create a feature branch from `main`:
|
||||
```bash
|
||||
git checkout -b feat/your-feature
|
||||
```
|
||||
|
||||
2. Make your changes and commit them
|
||||
|
||||
3. Run the pre-push verification commands (see
|
||||
[Pre-push verification](#pre-push-verification) above for the exact
|
||||
gate and the stricter release clippy form)
|
||||
|
||||
4. Push your branch and create a Pull Request
|
||||
|
||||
5. Describe your changes clearly in the PR description
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when opening
|
||||
a PR — what and why, the issue line, and how you tested it
|
||||
- The PR description needs one issue line, checked by CI
|
||||
(`.github/workflows/pr-issue-link.yml`): `Closes #N` (or `Fixes` /
|
||||
`Resolves`) when the PR finishes the issue, `Refs #N` for related or partial
|
||||
work, or `No-Issue: <one-line reason>`. Never write a negated closing
|
||||
keyword such as "does not close #N": GitHub closes the issue anyway, so CI
|
||||
rejects it
|
||||
- If you add a new layer, module, or abstraction, say which one it replaces
|
||||
or deletes
|
||||
- Keep PRs focused on a single change
|
||||
- Update documentation if needed
|
||||
- Add tests for new functionality
|
||||
- Get the pull-request checks green before requesting review, or say in the
|
||||
PR why one is red
|
||||
|
||||
## Shape of a Typical PR
|
||||
|
||||
A well-structured PR follows a consistent pattern. Recent exemplars include:
|
||||
|
||||
- **#386** — `/init` command: new `crates/tui/src/commands/groups/project/init.rs` module, project-type detection,
|
||||
AGENTS.md generation, command registration in `commands/mod.rs`, localization strings.
|
||||
- **#389** — Inline LSP diagnostics: LSP subsystem in `crates/tui/src/lsp/`, engine hooks in
|
||||
`crates/tui/src/core/engine/lsp_hooks.rs`, config toggle, test coverage.
|
||||
- **#387** — Self-update: new `crates/cli/src/update.rs` module, CLI subcommand registration,
|
||||
HTTP download + SHA256 verification + atomic binary replacement.
|
||||
- **#393** — `/share` session URL: new `crates/tui/src/commands/groups/project/share.rs`, HTML rendering,
|
||||
`gh gist create` integration, command registration.
|
||||
- **#343/#346** — (v0.8.5) Runtime thread/turn timeline and durable task manager refactors.
|
||||
|
||||
Typically each PR touches 1–3 new files, modifies 2–5 existing files for wiring
|
||||
(registries, dispatch matches, localization), and adds or updates tests. Changes
|
||||
are scoped to a single feature or fix — if you discover related work that needs
|
||||
doing, open a separate issue rather than expanding the PR scope.
|
||||
|
||||
Before submitting, run the commands in
|
||||
[Pre-push verification](#pre-push-verification).
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting issues, please use one of the issue templates:
|
||||
|
||||
- [Bug report](.github/ISSUE_TEMPLATE/bug_report.yml) — for reproducible problems
|
||||
or regressions
|
||||
- [Feature request](.github/ISSUE_TEMPLATE/feature_request.yml) — for ideas and
|
||||
improvements
|
||||
|
||||
The forms ask for what a report needs (`codewhale --version`, OS, how you got
|
||||
Codewhale, and steps to reproduce). Questions go to
|
||||
[Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or
|
||||
[Discord](https://discord.gg/37gfS3ksug).
|
||||
|
||||
## Security
|
||||
|
||||
Please do **not** open a public issue for a vulnerability. See
|
||||
[SECURITY.md](.github/SECURITY.md) for private reporting.
|
||||
If you discover a security vulnerability, please do **not** open a public issue.
|
||||
See [SECURITY.md](.github/SECURITY.md) for the responsible disclosure process and
|
||||
contact information.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Be respectful and inclusive. We welcome contributors of all backgrounds and
|
||||
experience levels. See [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md).
|
||||
experience levels. See [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md) for the full
|
||||
code of conduct.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Codewhale, you agree that your contributions will be
|
||||
licensed under the MIT License.
|
||||
By contributing to codewhale, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Questions?
|
||||
|
||||
Ask in [Discussions](https://github.com/codewhale-hq/CodeWhale/discussions) or
|
||||
on [Discord](https://discord.gg/37gfS3ksug), or add the maintainer, Hunter, on
|
||||
WeChat (`hunterbown`) and ask to join the Whale Brothers group. Everyone is
|
||||
welcome.
|
||||
Feel free to open an issue for any questions about contributing.
|
||||
Generated
+121
-1438
File diff suppressed because it is too large.
Load diff
+3
-19
@@ -1,10 +1,8 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/account-cli",
|
||||
"crates/agent",
|
||||
"crates/agent-contract",
|
||||
"crates/app-server",
|
||||
"crates/app-server-protocol",
|
||||
"crates/build-support",
|
||||
"crates/cli",
|
||||
"crates/cloud-facts",
|
||||
@@ -12,8 +10,6 @@ members = [
|
||||
"crates/commands",
|
||||
"crates/config",
|
||||
"crates/core",
|
||||
"crates/engine",
|
||||
"crates/error-taxonomy",
|
||||
"crates/execpolicy",
|
||||
"crates/hooks",
|
||||
"crates/lane",
|
||||
@@ -21,29 +17,17 @@ members = [
|
||||
"crates/mcp",
|
||||
"crates/memory",
|
||||
"crates/models",
|
||||
"crates/net",
|
||||
"crates/notify",
|
||||
"crates/palette",
|
||||
"crates/paths",
|
||||
"crates/plugins",
|
||||
"crates/protocol",
|
||||
"crates/release",
|
||||
"crates/runtime",
|
||||
"crates/sandbox",
|
||||
"crates/secrets",
|
||||
"crates/skill-install",
|
||||
"crates/skills",
|
||||
"crates/sanitize",
|
||||
"crates/state",
|
||||
"crates/telemetry",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
"crates/tui-kit",
|
||||
"crates/util",
|
||||
"crates/dependencies",
|
||||
"crates/snapshot",
|
||||
"crates/lsp",
|
||||
"crates/repl",
|
||||
"crates/workflow",
|
||||
"crates/workflow-js",
|
||||
"tests/portable-session",
|
||||
@@ -55,7 +39,7 @@ default-members = ["crates/cli"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.3"
|
||||
version = "0.10.2"
|
||||
edition = "2024"
|
||||
# The floor is what CI's MSRV job builds (1.89): Rust 1.88 stabilized the
|
||||
# `let_chains` the codebase relies on, but a locked dependency
|
||||
@@ -91,7 +75,7 @@ reqwest = { version = "0.13.1", default-features = false, features = ["json", "r
|
||||
# multi-thread engine over channels (see crates/workflow-js).
|
||||
rquickjs = { version = "0.14", features = ["futures"] }
|
||||
rustls = { version = "0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rusqlite = { version = "0.41.1", features = ["bundled"] }
|
||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
# DSH bundle import: event-level parsing so aliases and explicit tags can be refused.
|
||||
@@ -105,7 +89,7 @@ toml_edit = "0.25.12"
|
||||
sha2 = "0.11"
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
tracing = "0.1"
|
||||
gix = { version = "=0.90.0", default-features = false, features = ["sha1", "sha256"] }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
mimalloc = { version = "0.1", default-features = false }
|
||||
|
||||
+26
-17
@@ -1,20 +1,29 @@
|
||||
# Extension packager dependency security
|
||||
# Extension packager dependency acceptance
|
||||
|
||||
The VS Code extension's packaging toolchain uses `@vscode/vsce` →
|
||||
`@azure/identity` → `@azure/msal-node`. MSAL Node versions before 7.1.0 are
|
||||
affected by [GHSA-xcx6-jx8p-x3h5](https://github.com/AzureAD/microsoft-authentication-library-for-js/security/advisories/GHSA-xcx6-jx8p-x3h5).
|
||||
The extension manifest overrides Azure Identity's MSAL Node dependency to
|
||||
`^7.1.2`; its lockfile resolves 7.1.2. Keep the override until Azure Identity
|
||||
requires a patched version itself.
|
||||
The npm audit of `extensions/vscode` reports GHSA-xcx6-jx8p-x3h5, a high
|
||||
severity CWE-346 advisory in `@azure/msal-node` 6.0.1. It reaches the lockfile
|
||||
through `@vscode/vsce` 4.0.0 → `@azure/identity` 4.13.3 → `@azure/msal-node`, all
|
||||
of which are build-only tooling. There is no upstream-consistent fix: every
|
||||
published `@vscode/vsce` and `@azure/identity` release still declares
|
||||
`@azure/msal-node: ^6.0.0`, so only a semver-breaking `overrides` pin or a major
|
||||
downgrade of the packager would clear the report, and either changes the
|
||||
packaging tool's behaviour.
|
||||
|
||||
`npm run audit:dependencies -- --report <path>` audits the complete lockfile
|
||||
and keeps the raw report. Every reported vulnerability blocks the check.
|
||||
Incomplete reports, count mismatches and critical findings also fail. There
|
||||
is no advisory exception.
|
||||
This is a reviewed acceptance of an unfixed advisory, not a mitigation. The
|
||||
vulnerable package is a `devDependency`: `npm run package` invokes
|
||||
`vsce package --no-dependencies`, so no dependency of it ships in the vsix and
|
||||
the vulnerable authorization-code path is never executed when packaging. The
|
||||
extension's published contents and runtime dependency set are unchanged.
|
||||
|
||||
After changing this dependency, materialize the lockfile with `npm ci`, run
|
||||
the extension's existing `npm test` checks and build a local package with
|
||||
`npm run package`. Packaging uses `vsce package --no-dependencies`, which
|
||||
excludes development dependencies from the VSIX. A local package build does
|
||||
not establish that a publishing authentication flow works; verify that flow
|
||||
only when publication is separately authorized.
|
||||
`npm run audit:dependencies` audits the complete lockfile and keeps the raw
|
||||
report. Only this exact advisory, in `@azure/msal-node`, reachable through that
|
||||
one chain, can be classified as accepted. Any other advisory, a second
|
||||
`@azure/msal-node` installation, a critical finding, an incomplete audit, or a
|
||||
count mismatch fails the check. CI stores the raw report, including the accepted
|
||||
findings.
|
||||
|
||||
After an upstream fix is available, upgrade and verify it, then remove this
|
||||
acceptance and its classification in the same slice. Never broaden the admitted
|
||||
advisory list to get a passing check.
|
||||
|
||||
References: [upstream advisory](https://github.com/advisories/GHSA-xcx6-jx8p-x3h5).
|
||||
@@ -72,9 +72,8 @@ two copies end up on `PATH` and whichever comes first wins.
|
||||
## Quickstart
|
||||
|
||||
1. Commit or stash your work, then run `codewhale` from the root of a git
|
||||
repository. By default (**Accept Edits**) it edits files in the repository
|
||||
without asking and asks before each command; `Shift+Tab` steps the
|
||||
permission dial when you want it to ask more or less.
|
||||
repository. By default it applies file edits there without asking and
|
||||
shows you the diff.
|
||||
2. Run `/provider` (or press `F3`), pick a provider, paste its API key, and
|
||||
choose a model. DeepSeek is the default provider. With no key, pick a local
|
||||
runtime instead. If no provider is configured and Ollama is already serving
|
||||
@@ -103,14 +102,12 @@ codewhale exec --auto --sandbox workspace-write "fix the failing tests and expla
|
||||
|
||||
## Modes and permissions
|
||||
|
||||
A fresh install starts in Work mode with the Accept Edits permission. One dial
|
||||
switches both: `Shift+Tab` steps Ask → Accept Edits → Plan → Auto-Review →
|
||||
Full Access.
|
||||
A fresh install starts in Work mode with the Ask permission.
|
||||
|
||||
| | Switch with | Options |
|
||||
| --- | --- | --- |
|
||||
| Mode | the Plan stop on `Shift+Tab`, or `/mode` | **Plan** reads and searches but changes nothing. **Work** (every other stop) edits files and runs commands; start a goal with `/goal` when a request needs more than one turn. |
|
||||
| Permission | `Shift+Tab` | **Ask** asks before every file edit and every shell command that is not read-only. **Accept Edits** applies file edits in your git repository without asking and still asks before commands. **Auto-Review** lets rules and a reviewer model approve routine calls and refuses high-risk ones; switch to Ask to approve those yourself. **Full Access** never asks, and commands can reach anything on this computer; repository rules and managed policy still apply. |
|
||||
| Mode | `Tab` (when the input is empty) or `/mode` | **Plan** reads and searches but changes nothing. **Work** edits files and runs commands. **Operate** has the same access as Work, treats your request as a goal, and keeps going until the goal is verified or it needs input from you. |
|
||||
| Permission | `Shift+Tab` | **Ask** applies file edits in your repository without asking and asks before shell commands that are not read-only. **Auto-Review** lets rules and a reviewer model approve routine calls and refuses high-risk ones; switch to Ask to approve those yourself. **Full Access** never asks and turns off Codewhale's sandbox; repository rules and managed policy still apply. |
|
||||
|
||||
See the [modes and permissions guide](docs/MODES.md) for the details.
|
||||
|
||||
@@ -194,10 +191,10 @@ Connect [MCP servers](docs/MCP.md), add [skills](docs/SKILLS.md) and
|
||||
[plugins](docs/PLUGINS.md), and run [hooks](docs/HOOKS.md) on session and tool
|
||||
events. Code mode (experimental, on by default) lets the model write one
|
||||
`execute_tools` program that calls several MCP and plugin tools; each call
|
||||
still meets your approval rules and keeps its receipt. The independently installed Computer
|
||||
still meets your approval rules and keeps its receipt. The bundled Computer
|
||||
Use plugin (experimental, macOS only) can observe
|
||||
and operate other apps. It stays off until you review its access and enable it
|
||||
([guide](https://github.com/codewhale-hq/codewhale-cu-plugin)).
|
||||
([guide](crates/tui/plugins/computer-use/README.md)).
|
||||
|
||||
## Ways to run it
|
||||
|
||||
@@ -206,7 +203,7 @@ and operate other apps. It stays off until you review its access and enable it
|
||||
| `codewhale` | The interactive terminal app |
|
||||
| `codewhale exec "…"` | One task without the interface. On its own it answers without using tools; `--auto` enables tools with Full Access by default, `--sandbox workspace-write` restricts them, and `--output-format stream-json` streams events as JSON |
|
||||
| `codewhale web` | A local web UI on `127.0.0.1` ([guide](docs/WEB.md)) |
|
||||
| `codewhale review --pr N` | An advisory review of a GitHub pull request. The [GitHub bot setup](docs/GITHUB_ACTION.md#set-up-your-own-bot) generates a pinned workflow for automatic reviews and `@codewhale review`, using your own model key and optional App identity |
|
||||
| `codewhale review --pr N` | An advisory review of a GitHub pull request. It posts nothing unless you add `--post`; the [GitHub Action](docs/GITHUB_ACTION.md) runs the same review in CI |
|
||||
| `codewhale app-server --http` | The [Runtime API](docs/RUNTIME_API.md), a local HTTP API for threads, events, approvals, and each turn's file changes |
|
||||
| `codewhale serve --acp` | The Agent Client Protocol over stdio, for editors such as Zed |
|
||||
|
||||
@@ -258,19 +255,14 @@ that. More in [authorization order](docs/AUTHORIZATION_ORDER.md),
|
||||
|
||||
## Community
|
||||
|
||||
Codewhale is what it is because of its community, and all feedback is
|
||||
welcome: a bug, an idea, or something that looked or felt wrong.
|
||||
[Open an issue](https://github.com/codewhale-hq/CodeWhale/issues/new/choose) for any
|
||||
of these; an issue is also the best place to start a new feature. Pull requests
|
||||
are welcome for fixes, translations, providers and planned work. The
|
||||
[contributing guide](CONTRIBUTING.md) shows where each kind of help goes, and
|
||||
the [roadmap](docs/ROADMAP.md) shows where the project is headed. Contributors
|
||||
keep credit for the work that lands, and the
|
||||
[repository layout](CONTRIBUTING.md#project-structure) shows where things live.
|
||||
If a provider is missing or a workflow is awkward,
|
||||
[open an issue](https://github.com/codewhale-hq/CodeWhale/issues/new/choose) or
|
||||
[send a pull request](CONTRIBUTING.md). Contributors keep credit for the work
|
||||
that lands, and the [repository layout](CONTRIBUTING.md#project-structure)
|
||||
shows where things live.
|
||||
|
||||
Chat on [Discord](https://discord.gg/37gfS3ksug), or add the maintainer,
|
||||
Hunter, on WeChat (`hunterbown`) and ask to join the Whale Brothers group.
|
||||
Everyone is welcome.
|
||||
|
||||
## History and license
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# Reviewing a change to the Codewhale Engine
|
||||
|
||||
This is the written procedure for reviewing an Engine change: which passes to
|
||||
run, how to rank what they find, and how to report it. A person, an agent and
|
||||
`codewhale review` all follow the same passes, so two reviews of one change
|
||||
can be compared.
|
||||
|
||||
## When review runs
|
||||
|
||||
- **By hand**, when a maintainer or agent asks for one: `codewhale review`,
|
||||
`/review` in the terminal, or the `Codewhale PR Review` workflow, which only
|
||||
runs when someone starts it.
|
||||
- **At the frozen release candidate**, over every commit since the last
|
||||
release, as part of `docs/RELEASE_CHECKLIST.md`.
|
||||
- **Never as a required check.** For a contributor, review is advice. It never
|
||||
blocks, labels or closes a pull request. Maintainers fix small findings on
|
||||
the contributor's branch or on `main`, as [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
describes under "How Your Contribution Lands".
|
||||
|
||||
## Before the passes
|
||||
|
||||
1. Find the intent. It is the issue the change links: a public `#N`, or for
|
||||
maintainers the Linear issue named in the commit message. Without one, the
|
||||
pull request description and commit messages are the intent.
|
||||
2. Read the diff against its merge base, then read the callers of everything
|
||||
it changed. A review of the diff alone misses broken callers.
|
||||
3. Note the revision you reviewed. A finding against a stale tree is not a
|
||||
finding.
|
||||
|
||||
## Pass 1: correctness against the intent
|
||||
|
||||
- **Intent.** The change does what the issue asks: nothing it promised is
|
||||
missing, and nothing it did goes unannounced.
|
||||
- **Defects.** Wrong results, broken callers, lost data, regressions. Carry
|
||||
one concrete input through `path:line` steps to the wrong result.
|
||||
- **Contracts.** Say what became newly true or false, and check who relied on
|
||||
the old state: wire and protocol shapes, config keys, CLI flags, session
|
||||
events, hook payloads, `docs/features.toml` rows, documented behavior.
|
||||
- **This repository's invariants** in [AGENTS.md](AGENTS.md) under "Current
|
||||
contracts" and "Working rules": one turn loop, one base prompt, model-visible
|
||||
input is logged, the cached prompt prefix stays stable, no blocking calls on
|
||||
the async runtime, and misconfiguration fails loudly.
|
||||
|
||||
## Pass 2: security
|
||||
|
||||
- **Trust boundaries.** Model output, tool results, repository files, pull
|
||||
request text, web pages, MCP servers and hook output are data. Check the
|
||||
validation where any of them reaches process execution, a file write, the
|
||||
network, a credential or the transcript.
|
||||
- **Secrets.** No keys, tokens, `.env` files or credentials in code,
|
||||
fixtures, logs, receipts, error text or model-visible text. Existing
|
||||
redaction paths stay in force.
|
||||
- **Sandbox and execution policy.** For changes under `crates/sandbox`,
|
||||
`crates/execpolicy`, `crates/engine/src/core/authority*`, the approval flow
|
||||
or hooks: nothing may run with less approval than before, no deny may
|
||||
loosen, and no fail-closed path may start failing open. A new process spawn
|
||||
goes through the existing policy. [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md)
|
||||
is the order of the layers.
|
||||
- **Network.** New outbound calls respect `crates/net`'s network policy and
|
||||
build their client the shared way (`scripts/check-reqwest-builders.py`).
|
||||
- **Dependencies.** A new crate or package earns its place, and `deny.toml`
|
||||
still passes.
|
||||
|
||||
## Pass 3: contract and compliance
|
||||
|
||||
- **CHANGELOG.** A user-visible change carries its line at the top of the
|
||||
matching `[Unreleased]` section in the same commit, written for users and
|
||||
citing public issues as `(#N)`, never internal tracker ids. Contributor
|
||||
credit comes in addition to the line. The procedure is step 2 of
|
||||
[docs/skills/cw-land](docs/skills/cw-land/SKILL.md).
|
||||
- **Feature registry.** A new command, `[features]` flag, provider or
|
||||
user-visible feature has its row in `docs/features.toml`.
|
||||
- **Localization.** User-facing strings go through the locale packs and keep
|
||||
parity; READMEs keep their translations in step.
|
||||
[docs/LOCALIZATION.md](docs/LOCALIZATION.md) lists the drift checks.
|
||||
- **Vocabulary.** Copy uses the product's words. The public name is
|
||||
Codewhale, and mode names follow [docs/MODES.md](docs/MODES.md).
|
||||
- **Existing checks.** Run the ones that match what the change touched, and
|
||||
report their real output:
|
||||
|
||||
| The change touched | Run |
|
||||
| --- | --- |
|
||||
| Any Rust | `cargo fmt --all -- --check`, then `scripts/dev-test.sh <area or path>` |
|
||||
| Generated files and ratchets | `scripts/preflight.sh --check` |
|
||||
| `#[allow(dead_code)]` | `python3 scripts/check-dead-code-budget.py` |
|
||||
| Async code | `python3 scripts/check-blocking-calls-budget.py` |
|
||||
| TUI strings | `python3 scripts/check-tui-locale-parity.py`, `scripts/check-tui-product-vocabulary.sh` |
|
||||
| User-facing English copy | `python3 scripts/check-lexicon.py` |
|
||||
| READMEs | `python3 scripts/check-readme-translations.py`, `scripts/check-readme-locales.sh` |
|
||||
| The provider registry | `python3 scripts/check-provider-registry.py` |
|
||||
| `config.example.toml` | `python3 scripts/check-config-example.py` |
|
||||
| A feature flag or command | `cargo test -p codewhale-tui --test feature_registry` |
|
||||
| `CHANGELOG.md` or credit | `sh scripts/sync-changelog.sh`, `python3 scripts/check-contributor-credit.py` |
|
||||
| Versions | `bash scripts/release/check-versions.sh` |
|
||||
|
||||
Do not ask for new tests or code comments; this repository adds neither unless
|
||||
an issue asks. Recommend a regression test only for a specific failure you can
|
||||
explain.
|
||||
|
||||
## Severity
|
||||
|
||||
Use the three levels `codewhale review` reports:
|
||||
|
||||
- **blocker**: a demonstrated severe failure, such as lost data, a security
|
||||
hole, a broken build or install, a crash or wrong result on a common path,
|
||||
or a broken public contract.
|
||||
- **concern**: a concrete, narrower defect with a named trigger.
|
||||
- **nit**: a demonstrated low-impact defect, such as a misleading message or a
|
||||
doc the change made wrong.
|
||||
|
||||
Every finding carries its evidence: **reproduced** (a command you ran and its
|
||||
output), **traced** (one input carried through `path:line` steps) or
|
||||
**suspected** (anything less). A suspected finding is a question for a person,
|
||||
never a blocker. Do not raise severity to express uncertainty. A defect the
|
||||
change did not introduce is labeled pre-existing and does not count against
|
||||
the change.
|
||||
|
||||
## Nits
|
||||
|
||||
Group nits into one list instead of one comment each. Leave formatting to
|
||||
`cargo fmt` and clippy. When nits are all a review found, say the change is
|
||||
fine to merge and leave the list for the author to take or leave.
|
||||
|
||||
## Report
|
||||
|
||||
- Findings ranked blocker, then concern, then nit, each with `path:line`, the
|
||||
trigger, the impact, the smallest fix and its evidence.
|
||||
- What you verified (commands and results), kept apart from what you only
|
||||
read, and what you did not review.
|
||||
- On a contributor's pull request, thank them for the specific thing they did,
|
||||
keep every finding actionable, and offer to fix small ones on their branch.
|
||||
+25
-75
@@ -46,24 +46,6 @@ poll-based render loop; the file header records the change.
|
||||
|
||||
Upstream: <https://github.com/openai/codex>
|
||||
|
||||
The App Server request-ID, RPC envelope and initialization contracts in
|
||||
`crates/app-server-protocol/src/{rpc,lifecycle}.rs` are adapted from official
|
||||
Codex `rust-v0.166.0`, commit `6ff1e1ca3b30fc8b914720681fcf5fe62e95f856`.
|
||||
The changes and exact source paths are recorded in that crate's `UPSTREAM.md`.
|
||||
The crate retains `LICENSE-APACHE`, `LICENSE-MIT` and the upstream `NOTICE`;
|
||||
its combined license expression is `MIT AND Apache-2.0`.
|
||||
|
||||
Upstream NOTICE, retained unchanged:
|
||||
|
||||
```text
|
||||
OpenAI Codex
|
||||
Copyright 2025 OpenAI
|
||||
|
||||
This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
|
||||
Copyright (c) 2016-2022 Florian Dehau
|
||||
Copyright (c) 2023-2025 The Ratatui Developers
|
||||
```
|
||||
|
||||
```
|
||||
Copyright 2025 OpenAI
|
||||
|
||||
@@ -86,12 +68,12 @@ The full Apache License 2.0 text ships in this repository at
|
||||
## Cordis, schemastery and cosmokit (Shigma, via DeepSeek Harness) — MIT
|
||||
|
||||
The embedded extension host bundle
|
||||
(`crates/engine/extension-host/dist/codewhale-extension-host.mjs`) contains these
|
||||
(`crates/tui/extension-host/dist/codewhale-extension-host.mjs`) contains these
|
||||
packages, as published by DeepSeek Harness:
|
||||
|
||||
- `@deepseek-ai/cordis` 4.1.0
|
||||
- `@deepseek-ai/schemastery` 3.19.0
|
||||
- `@deepseek-ai/cosmokit` 1.9.0
|
||||
- `@deepseek-ai/cordis` 4.0.4
|
||||
- `@deepseek-ai/schemastery` 3.18.4
|
||||
- `@deepseek-ai/cosmokit` 1.8.5
|
||||
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness> (`vendor/cordis`,
|
||||
`vendor/schemastery`, `vendor/cosmokit`)
|
||||
@@ -120,18 +102,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## DeepSeek Harness utility packages (`dsh-util-values`, `dsh-tools`, message compatibility) — MIT
|
||||
## DeepSeek Harness utility packages (`dsh-util-values`, `dsh-tools`) — MIT
|
||||
|
||||
The same bundle contains `@deepseek-ai/dsh-util-values` 0.2.2-rc.1, and
|
||||
`crates/engine/extension-host/src/dsh/dsh-tools-compat.js` contains verbatim
|
||||
excerpts of `@deepseek-ai/dsh-tools` 0.2.2-rc.1 (`lib/index.js` and its shared
|
||||
`lib/shared-DTdn5dXO.js` chunk), under the same licence text.
|
||||
`crates/engine/extension-host/src/dsh/dsh-message-compat.js` contains verbatim
|
||||
excerpts of `@deepseek-ai/dsh-brand` 0.2.2-rc.1,
|
||||
`@deepseek-ai/dsh-llm` 0.2.2-rc.1, `@deepseek-ai/dsh-session` 0.2.2-rc.1,
|
||||
`@deepseek-ai/dsh-system-prompt` 0.2.2-rc.1,
|
||||
`@deepseek-ai/dsh-user-questions` 0.2.2-rc.1 and
|
||||
`@deepseek-ai/dsh-util-crypto` 0.2.2-rc.1, under the same licence text.
|
||||
The same bundle contains `@deepseek-ai/dsh-util-values` 0.1.7-alpha.2, and
|
||||
`crates/tui/extension-host/src/dsh/dsh-tools-compat.js` contains verbatim
|
||||
excerpts of `@deepseek-ai/dsh-tools` 0.1.7-alpha.2 (`lib/index.js`, lines 8-888
|
||||
and 2528-2558), under the same licence text.
|
||||
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
|
||||
(`dsh-util-values` is published from `packages/util/values`)
|
||||
@@ -166,13 +142,13 @@ materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
|
||||
|
||||
## Model Context Protocol SDK and bundled dependencies
|
||||
|
||||
The pinned builtin MCP bundle (`crates/engine/extension-host/dist/builtin/mcp.mjs`)
|
||||
The pinned builtin MCP bundle (`crates/tui/extension-host/dist/builtin/mcp.mjs`)
|
||||
contains the following packages. The MCP packages are labeled Apache-2.0 and
|
||||
carry the upstream licensing-transition statement and full Apache-2.0 text
|
||||
reproduced below. The other packages are labeled MIT. Their exact distributed
|
||||
notices are preserved.
|
||||
|
||||
### `@modelcontextprotocol/client` 2.4.0
|
||||
### `@modelcontextprotocol/client` 2.3.1
|
||||
|
||||
```text
|
||||
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
|
||||
@@ -393,7 +369,7 @@ CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
|
||||
the full license text.
|
||||
```
|
||||
|
||||
### `@modelcontextprotocol/core` 2.4.0
|
||||
### `@modelcontextprotocol/core` 2.3.1
|
||||
|
||||
```text
|
||||
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
|
||||
@@ -749,9 +725,9 @@ SOFTWARE.
|
||||
|
||||
The host and its offline composition reviewer also contain these packages.
|
||||
Vendored composition adaptations are recorded in
|
||||
`crates/engine/extension-host/src/dsh/upstream/UPSTREAM.json`.
|
||||
`crates/tui/extension-host/src/dsh/upstream/UPSTREAM.json`.
|
||||
|
||||
### `@deepseek-ai/cordis-plugin-group` 1.1.0
|
||||
### `@deepseek-ai/cordis-plugin-group` 1.0.2
|
||||
|
||||
```text
|
||||
MIT License
|
||||
@@ -777,7 +753,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `@deepseek-ai/cordis-plugin-include` 1.1.0
|
||||
### `@deepseek-ai/cordis-plugin-include` 1.0.7
|
||||
|
||||
```text
|
||||
MIT License
|
||||
@@ -803,7 +779,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `@deepseek-ai/cordis-plugin-loader` 1.1.0
|
||||
### `@deepseek-ai/cordis-plugin-loader` 1.0.3
|
||||
|
||||
```text
|
||||
MIT License
|
||||
@@ -889,26 +865,21 @@ THE SOFTWARE.
|
||||
The embedded extension host includes these pinned DeepSeek Harness packages:
|
||||
|
||||
- `@deepseek-ai/dsh-agent-presets` 0.1.6-alpha.1
|
||||
- `@deepseek-ai/dsh-hook-protocol` 0.2.2-rc.1
|
||||
- `@deepseek-ai/dsh-hooks-claude-code` 0.2.2-rc.1
|
||||
- `@deepseek-ai/dsh-hooks-codex` 0.2.2-rc.1
|
||||
- `@deepseek-ai/dsh-persona` 0.2.2-rc.1
|
||||
- `@deepseek-ai/dsh-system-prompt` 0.2.2-rc.1
|
||||
- `@deepseek-ai/dsh-hook-protocol` 0.1.6-alpha.1
|
||||
- `@deepseek-ai/dsh-hooks-claude-code` 0.1.6-alpha.1
|
||||
- `@deepseek-ai/dsh-hooks-codex` 0.1.6-alpha.1
|
||||
- `@deepseek-ai/dsh-persona` 0.1.6-alpha.1
|
||||
- `@deepseek-ai/dsh-system-prompt` 0.1.6-alpha.1
|
||||
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness>, tag
|
||||
`dsh-v0.2.2-rc.1`, commit `66491b05e3f05ad35bcdd4f6cd7ed26041d9e3db`.
|
||||
The legacy `dsh-agent-presets` adapter alone retains commit
|
||||
`0d1f50007f9bca3f52b06e1c3074fa14d5fb0720`; the newer preset runtime packages
|
||||
are not bundled. The reviewed composition loader is refreshed to the rc.1
|
||||
source: `cordis-plugin-loader` 1.1.0, `cordis-plugin-include` 1.1.0 and
|
||||
`cordis-plugin-group` 1.1.0 (MIT, Shigma).
|
||||
Upstream: <https://github.com/deepseek-ai/deepseek-harness>, commit
|
||||
`0d1f50007f9bca3f52b06e1c3074fa14d5fb0720`.
|
||||
|
||||
Preset discovery and metadata, hook matching and dialect codecs, and additive
|
||||
persona templates run through receipt-backed inputs. Core retains selection,
|
||||
prompt assembly, sessions, approvals and writable roots. Original persona source
|
||||
hashes are recorded in `crates/engine/extension-host/src/dsh/persona.UPSTREAM.json`.
|
||||
hashes are recorded in `crates/tui/extension-host/src/dsh/persona.UPSTREAM.json`.
|
||||
The identical upstream license text for all six packages is also carried in
|
||||
`crates/engine/extension-host/dist/LICENSES.txt`.
|
||||
`crates/tui/extension-host/dist/LICENSES.txt`.
|
||||
|
||||
```
|
||||
MIT License
|
||||
@@ -934,27 +905,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
### `semver` 7.8.5
|
||||
|
||||
```text
|
||||
The ISC License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
```
|
||||
|
||||
## Optional compiled extension host
|
||||
|
||||
Node remains the default extension-host runtime. An explicitly installed
|
||||
|
||||
+6
-23
@@ -1,31 +1,20 @@
|
||||
name: Codewhale
|
||||
description: Review pull requests automatically or on an authorized @codewhale review request, using your chosen model.
|
||||
branding:
|
||||
icon: code
|
||||
color: blue
|
||||
name: Codewhale review
|
||||
description: Review a pinned pull request with a checksummed Codewhale release and an explicit outcome receipt.
|
||||
inputs:
|
||||
version:
|
||||
description: Exact released CLI tag (vX.Y.Z). Must support review --json.
|
||||
required: true
|
||||
provider:
|
||||
description: Explicit BYOK provider (orcarouter, deepseek, anthropic, openrouter, zai, modelstudio-token-plan), or codewhale for a separately verified account machine key.
|
||||
required: true
|
||||
description: codewhale for an account machine key, or an explicit BYOK provider.
|
||||
default: codewhale
|
||||
model:
|
||||
description: Exact model ID. Account mode requires provider/model from the account catalog.
|
||||
required: true
|
||||
reasoning-effort:
|
||||
description: Optional Engine reasoning setting (auto, off, low, medium, high, max).
|
||||
github-token:
|
||||
description: GitHub token with contents read and pull-requests read; legacy post:true also requires pull-requests write.
|
||||
description: GitHub token with contents read and pull-requests write.
|
||||
default: ${{ github.token }}
|
||||
pr-number:
|
||||
description: PR number for workflow_dispatch; pull_request events use the event snapshot.
|
||||
trigger-phrase:
|
||||
description: Bot mention used for a standalone @name review line in a new PR comment.
|
||||
default: '@codewhale'
|
||||
allow-forks:
|
||||
description: Allow inert fork review only on a write-authorized issue_comment or manual run from the default branch.
|
||||
default: 'false'
|
||||
max-chars:
|
||||
description: Maximum diff characters per complete pass.
|
||||
default: '200000'
|
||||
@@ -38,7 +27,7 @@ inputs:
|
||||
description: Model and publication deadline, in seconds (30–1200).
|
||||
default: '600'
|
||||
post:
|
||||
description: Legacy direct COMMENT publication. Set false to export findings for a separate publisher job.
|
||||
description: Publish a COMMENT review; never approve or request changes.
|
||||
default: 'true'
|
||||
outputs:
|
||||
outcome:
|
||||
@@ -47,9 +36,6 @@ outputs:
|
||||
receipt:
|
||||
description: Absolute path to the sanitized JSON execution receipt (also written on failures).
|
||||
value: ${{ steps.review.outputs.receipt }}
|
||||
findings:
|
||||
description: Absolute path to validated findings JSON for a complete post:false review; contains repository-sensitive review text.
|
||||
value: ${{ steps.review.outputs.findings }}
|
||||
pr-url:
|
||||
description: URL of the pull request, when resolved.
|
||||
value: ${{ steps.review.outputs.pr-url }}
|
||||
@@ -63,10 +49,7 @@ runs:
|
||||
CW_VERSION: ${{ inputs.version }}
|
||||
CW_PROVIDER: ${{ inputs.provider }}
|
||||
CW_MODEL: ${{ inputs.model }}
|
||||
CW_REASONING_EFFORT: ${{ inputs.reasoning-effort }}
|
||||
CW_PR_NUMBER: ${{ inputs.pr-number }}
|
||||
CW_TRIGGER_PHRASE: ${{ inputs.trigger-phrase }}
|
||||
CW_ALLOW_FORKS: ${{ inputs.allow-forks }}
|
||||
CW_MAX_CHARS: ${{ inputs.max-chars }}
|
||||
CW_MAX_PASSES: ${{ inputs.max-passes }}
|
||||
CW_MAX_OUTPUT_TOKENS: ${{ inputs.max-output-tokens }}
|
||||
|
||||
+25
-44
@@ -82,7 +82,6 @@ default_text_model = "deepseek-flash"
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Thinking Mode (DeepSeek V4 reasoning effort)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# "auto" — the route chooses per request (default)
|
||||
# "off" — disables chain-of-thought (thinking.type = disabled)
|
||||
# "low" — compat-maps to "high" server-side
|
||||
# "medium" — compat-maps to "high" server-side
|
||||
@@ -92,7 +91,7 @@ default_text_model = "deepseek-flash"
|
||||
# Ctrl+T in the TUI cycles the reasoning tier. The header shows the current
|
||||
# tier as a ⚡ chip. (Shift+Tab cycles the permission posture — Ask /
|
||||
# Auto-Review / Full Access — not the reasoning tier.)
|
||||
reasoning_effort = "auto"
|
||||
reasoning_effort = "max"
|
||||
|
||||
# NOTE: `show_thinking`, `thinking_default_expanded`, `thinking_preview_lines`,
|
||||
# `help_expand_groups`, `pin_last_prompt`, and `cost_currency` live in
|
||||
@@ -169,25 +168,11 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
|
||||
# "muse", or "all". Env: CODEWHALE_PROJECT_INSTRUCTION_IMPORTS (comma-separated).
|
||||
# project_instruction_imports = ["claude"]
|
||||
#
|
||||
# Standing instructions are budgeted per scope, in estimated tokens, and every
|
||||
# trim or skip is reported to the model and in /context.
|
||||
# `global` covers ~/.codewhale/AGENTS.md (or ~/.agents/AGENTS.md), the global
|
||||
# instructions.md fallbacks, ~/.claude/CLAUDE.md (last resort, claude_fallback
|
||||
# only) and the `instructions = [...]` array. `project` covers the
|
||||
# repository-root -> workspace AGENTS.md chain, co-read foreign files, and
|
||||
# .codewhale/rules/*.md. A scope is capped at the smaller of its absolute token
|
||||
# budget and its share of the usable window (context window minus the output
|
||||
# reservation and a 10% safety reserve). Each file keeps at most its first
|
||||
# 100 KiB. User config only: a cloned repository cannot change it.
|
||||
# [instruction_budget]
|
||||
# global_budget_tokens = 100000 # positive; absolute cap for the global scope
|
||||
# project_budget_tokens = 100000 # positive; absolute cap for the project scope
|
||||
# global_budget_percent = 25 # 1..=100, share of the usable window
|
||||
# project_budget_percent = 25 # 1..=100, share of the usable window
|
||||
# static_prefix_guard_percent = 80 # 1..=100; guard must leave >=10 points
|
||||
# file_max_bytes = 8388608 # 1..=8388608; larger files are skipped
|
||||
# policy = "trim" # trim | refuse
|
||||
# claude_fallback = false # opt in: AGENTS.md wins; else CLAUDE.md
|
||||
# Everything that reaches the model as standing project instruction authority —
|
||||
# the repository-root -> workspace AGENTS.md chain, the global fallback layer,
|
||||
# .codewhale/rules/*.md, and any imported foreign files — shares one 48 KiB
|
||||
# aggregate budget. Instructions claim it first and are trimmed from the
|
||||
# broadest scope inward, so the nearest-scope file is the last thing dropped.
|
||||
# prompt_suggestion = true # opt-in: show ghost-text follow-up question in composer after each turn
|
||||
|
||||
# Optional tab/window title shown as `[title] …` in front of the terminal
|
||||
@@ -525,11 +510,6 @@ show_whats_new = true
|
||||
# 0 disables the timeout entirely; values above 86400 (24h) are clamped.
|
||||
# user_input_timeout_seconds = 300
|
||||
#
|
||||
# Fail-safe ceiling, in seconds, for how long a durable task (runtime API
|
||||
# server) may sit on an unanswered approval or question before it fails with
|
||||
# human_wait_timeout. 0 or omitted uses 1800; values are clamped to 1..=86400.
|
||||
# task_human_wait_cap_seconds = 1800
|
||||
#
|
||||
# Bound an interactive approval card's wait (#6101). When the window elapses
|
||||
# the card resolves to deny (fail-closed) and the transcript says the bound
|
||||
# denied the call; omitted or 0 waits indefinitely.
|
||||
@@ -897,13 +877,12 @@ base_url = "https://api.deepseek.com/beta"
|
||||
# api_key = "YOUR_XAI_API_KEY"
|
||||
# auth_mode = "oauth" # or "device_code" / "grok_cli"
|
||||
# base_url = "https://api.x.ai/v1"
|
||||
# model = "grok-4.7" # or grok-4.6, grok-4.5, grok-4.3, grok-build
|
||||
# model = "grok-4.6" # or grok-4.5, grok-4.3, grok-build
|
||||
|
||||
# Mistral AI — la Plateforme (https://console.mistral.ai/)
|
||||
# OpenAI-compatible Chat Completions route.
|
||||
# Provider aliases: mistral, mistral-ai, mistralai, la-plateforme
|
||||
# Env var aliases: MISTRAL_API_KEY, MISTRAL_BASE_URL, MISTRAL_MODEL
|
||||
# Vibe Code subscription setup: docs/MISTRAL_VIBE.md
|
||||
[providers.mistral]
|
||||
# api_key = "YOUR_MISTRAL_API_KEY"
|
||||
# base_url = "https://api.mistral.ai/v1"
|
||||
@@ -1186,9 +1165,9 @@ base_url = "https://api.deepseek.com/beta"
|
||||
[stream]
|
||||
open_timeout_secs = 45 # response headers, including connect (0 = env/default, 5-300)
|
||||
chunk_timeout_secs = 900 # per-SSE-chunk idle bound (0 = default, 1-3600)
|
||||
max_resumes = 3 # proven pre-dispatch connection recovery (0 = off, 0-10)
|
||||
max_transparent_retries = 2 # compatibility value; interrupted streams never reissue
|
||||
max_stream_errors = 5 # compatibility value; uncertain stream errors end the stream
|
||||
max_resumes = 3 # whole-request reissues after failed streams (0 = off, 0-10)
|
||||
max_transparent_retries = 2 # retry before any content (0 = off, 0-10)
|
||||
max_stream_errors = 5 # recoverable errors per stream (0 = default, 1-50)
|
||||
max_duration_secs = 1800 # one stream, not the whole turn (0 = default, 10-86400)
|
||||
max_content_mb = 10 # one stream's content (0 = default, 1-512)
|
||||
connect_timeout_secs = 30 # TCP/TLS setup (0 = default, 1-300)
|
||||
@@ -1288,18 +1267,20 @@ code_mode = true # execute_tools composes MCP/plugin/native calls; false defers
|
||||
# a prompt under Full Access, under Bypass, or with a
|
||||
# matching session grant, which is bound to the
|
||||
# plugin's reviewed build (an update asks again).
|
||||
# Native code requires a verified OS sandbox: Seatbelt
|
||||
# on macOS, bubblewrap on Linux, and LPAC/Job on Windows.
|
||||
# An unavailable Native sandbox refuses activation.
|
||||
# Unix reads are limited to the selected runtime and
|
||||
# libraries, canonical host bundle, captured Core-managed
|
||||
# plugin stores and Native data. Writes stay in Native
|
||||
# data and private scratch; repository reads and direct
|
||||
# network are denied. Native plugins share one trusted
|
||||
# cohort, not separate per-plugin OS sandboxes.
|
||||
# On macOS a detached child can outlive process-group
|
||||
# cancellation while retaining the sandbox restrictions.
|
||||
# Builtin host policy is separate and diagnosed separately.
|
||||
# Sandbox: on macOS the host runs under Seatbelt, and
|
||||
# on Linux under bubblewrap (/usr/bin/bwrap) when a
|
||||
# launch-time probe shows it works. Either way: no
|
||||
# direct network, no reads of ~/.codewhale secrets/
|
||||
# credentials/config/sessions, and writes only to its
|
||||
# data dir and temp dirs (plus, on macOS, the Darwin
|
||||
# user cache, ~/.cargo/registry, ~/.cargo/git and the
|
||||
# npm cache, which every Seatbelt-sandboxed command
|
||||
# gets). If bwrap is missing or cannot start (for
|
||||
# example blocked unprivileged user namespaces), the
|
||||
# host runs UNSANDBOXED with your user permissions and
|
||||
# /plugin, `codewhale doctor` and the start diagnostic
|
||||
# say so with bwrap's own error. On Windows it always
|
||||
# runs with your user permissions.
|
||||
# Toggling this changes the plugin activation policy,
|
||||
# so every plugin must be re-reviewed afterwards.
|
||||
|
||||
@@ -1763,7 +1744,7 @@ base_url = "https://integrate.api.nvidia.com/v1"
|
||||
# # Optional owner-selected total child limit. Zero leaves work uncapped.
|
||||
# max_children = 0
|
||||
# # Maximum concurrently live agents inside one run (others wait for a slot).
|
||||
# max_concurrent = 64
|
||||
# max_concurrent = 16
|
||||
# # Optional structural Workflow depth limit; zero leaves it uncapped.
|
||||
# # This is separate from Runtime child delegation below.
|
||||
# max_depth = 0
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "codewhale-account-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Codewhale account, cloud computer and dispatch commands for the CLI"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-config = { path = "../config", version = "0.10.3" }
|
||||
codewhale-release = { path = "../release", version = "0.10.3" }
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.3" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
|
||||
console = "0.16.3"
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
webbrowser = "1.0"
|
||||
zeroize = "1.8.2"
|
||||
@@ -1,688 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use codewhale_secrets::account::{AccountDeviceAccess, AccountSessionState, RuntimeAccountInfo};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn account_api_base(api_base: Option<&str>) -> Result<String> {
|
||||
let requested_base = machine::resolve_api_base(
|
||||
api_base,
|
||||
std::env::var(machine::MACHINE_API_BASE_ENV).ok().as_deref(),
|
||||
std::env::var(CLOUD_API_BASE_ENV).ok().as_deref(),
|
||||
DEFAULT_API_BASE,
|
||||
);
|
||||
Ok(validate_api_base(&requested_base)?.display)
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountStatus {
|
||||
pub profile: String,
|
||||
pub api_base: String,
|
||||
pub account: RuntimeAccountInfo,
|
||||
pub user: Option<CloudUser>,
|
||||
pub revision: String,
|
||||
pub import_allowed: bool,
|
||||
}
|
||||
|
||||
pub struct AccountAuthLease {
|
||||
pub bundle: AuthBundle,
|
||||
pub snapshot: AccountSessionSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountModel {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub protocol: String,
|
||||
pub billing_mode: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
pub struct AccountModels {
|
||||
pub api_base: String,
|
||||
pub account_id: String,
|
||||
pub session_id: String,
|
||||
pub expires_at: i64,
|
||||
pub default_model: Option<String>,
|
||||
pub models: Vec<AccountModel>,
|
||||
pub credential: Option<String>,
|
||||
pub snapshot: AccountSessionSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AccountClient {
|
||||
transport: ReqwestTransport,
|
||||
store: AccountSessionStore,
|
||||
profile: String,
|
||||
api_base: String,
|
||||
}
|
||||
|
||||
impl AccountClient {
|
||||
pub fn new(profile: Option<&str>, api_base: Option<&str>) -> Result<Self> {
|
||||
let requested_base = account_api_base(api_base)?;
|
||||
let base = validate_api_base(&requested_base)?;
|
||||
let profile = normalized_profile(profile);
|
||||
let secrets = cloud_session_secrets()?;
|
||||
Ok(Self {
|
||||
transport: ReqwestTransport::new(base.url)?,
|
||||
store: AccountSessionStore::new(secrets, Some(&profile), &base.display),
|
||||
profile,
|
||||
api_base: base.display,
|
||||
})
|
||||
}
|
||||
|
||||
fn client(&self) -> CloudClient<'_, ReqwestTransport> {
|
||||
CloudClient {
|
||||
transport: &self.transport,
|
||||
account_store: self.store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn revoke_session_later(&self, previous: Option<AccountSessionSnapshot>, bundle: AuthBundle) {
|
||||
let client = self.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("cw-account-revoke".into())
|
||||
.spawn(move || {
|
||||
let _ = match previous {
|
||||
Some(previous) => client.client().revoke_previous_session(&previous, &bundle),
|
||||
None => client.client().revoke_session_bundle(&bundle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Result<AccountSessionSnapshot> {
|
||||
Ok(self.store.snapshot()?)
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Result<AccountStatus> {
|
||||
let snapshot = self.snapshot()?;
|
||||
let user = snapshot.load()?.and_then(|stored| stored.bundle.user);
|
||||
Ok(AccountStatus {
|
||||
profile: self.profile.clone(),
|
||||
api_base: self.api_base.clone(),
|
||||
account: snapshot.runtime_info_at(Utc::now())?,
|
||||
user,
|
||||
revision: snapshot.fingerprint(),
|
||||
import_allowed: snapshot.is_pristine(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn claim(&self) -> Result<AccountSessionSnapshot> {
|
||||
Ok(self.store.begin_login()?)
|
||||
}
|
||||
|
||||
pub fn claim_if_unchanged(
|
||||
&self,
|
||||
expected_revision: &str,
|
||||
) -> Result<Option<AccountSessionSnapshot>> {
|
||||
self.store.with_transaction(|transaction| {
|
||||
if transaction.snapshot().fingerprint() != expected_revision {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(transaction.renew_revision()?))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel_claim(&self, expected_revision: &str) -> Result<bool> {
|
||||
self.store.with_transaction(|transaction| {
|
||||
if transaction.snapshot().fingerprint() != expected_revision {
|
||||
return Ok(false);
|
||||
}
|
||||
transaction.renew_revision()?;
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn import(
|
||||
&self,
|
||||
bundle: AuthBundle,
|
||||
expected_revision: &str,
|
||||
replace_existing: bool,
|
||||
) -> Result<bool> {
|
||||
self.import_with_receipt(bundle, expected_revision, replace_existing)
|
||||
.map(|receipt| receipt.is_some())
|
||||
}
|
||||
|
||||
pub fn import_with_receipt(
|
||||
&self,
|
||||
bundle: AuthBundle,
|
||||
expected_revision: &str,
|
||||
replace_existing: bool,
|
||||
) -> Result<Option<AccountSessionSnapshot>> {
|
||||
validate_auth_bundle(&bundle)?;
|
||||
let expected = self.snapshot()?;
|
||||
if expected.fingerprint() != expected_revision {
|
||||
return Ok(None);
|
||||
}
|
||||
if !replace_existing && !expected.is_pristine() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(committed) = self.store.save_if_unchanged(&expected, bundle.clone())? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.revoke_session_later(Some(expected), bundle);
|
||||
Ok(Some(committed))
|
||||
}
|
||||
|
||||
pub fn fresh_auth(&self, rejected_token: Option<&str>) -> Result<Option<AccountAuthLease>> {
|
||||
let snapshot = self.snapshot()?;
|
||||
let Some(stored) = snapshot.load()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if matches!(
|
||||
snapshot.runtime_info_at(Utc::now())?.state,
|
||||
AccountSessionState::Expired | AccountSessionState::Revoked
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
let refresh = rejected_token == Some(stored.bundle.access_token.as_str())
|
||||
|| !stored
|
||||
.bundle
|
||||
.session
|
||||
.as_ref()
|
||||
.and_then(|session| DateTime::parse_from_rfc3339(&session.expires_at).ok())
|
||||
.is_some_and(|expires| expires.timestamp() > Utc::now().timestamp() + 120);
|
||||
let (bundle, snapshot) = if refresh {
|
||||
let Some(renewed) = self.client().refresh_snapshot(&snapshot)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
renewed
|
||||
} else {
|
||||
(stored.bundle, snapshot)
|
||||
};
|
||||
let current = self.store.with_transaction(|transaction| {
|
||||
Ok::<_, anyhow::Error>(transaction.matches(&snapshot))
|
||||
})?;
|
||||
Ok(current.then_some(AccountAuthLease { bundle, snapshot }))
|
||||
}
|
||||
|
||||
pub fn logout(&self) -> Result<bool> {
|
||||
self.client().logout()
|
||||
}
|
||||
|
||||
pub fn logout_if_unchanged(&self, snapshot: &AccountSessionSnapshot) -> Result<bool> {
|
||||
self.client().logout_from_snapshot(snapshot)
|
||||
}
|
||||
|
||||
pub fn connect_models(&self) -> Result<AccountModels> {
|
||||
self.connect_models_with_cancel(&AtomicBool::new(false))
|
||||
}
|
||||
|
||||
pub fn connect_models_with_cancel(&self, cancelled: &AtomicBool) -> Result<AccountModels> {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
bail!("Codewhale account model connection cancelled");
|
||||
}
|
||||
let lease = self
|
||||
.fresh_auth(None)?
|
||||
.ok_or_else(|| anyhow!("Sign in to Codewhale before connecting account models"))?;
|
||||
let (response, snapshot) = self.client().execute_from_snapshot(
|
||||
HttpMethod::Get,
|
||||
"/api/account/model-api",
|
||||
None,
|
||||
lease.snapshot,
|
||||
)?;
|
||||
let catalog: serde_json::Value = expect_json(response, &[200])?;
|
||||
let data = catalog
|
||||
.get("data")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.filter(|rows| rows.len() <= 8192)
|
||||
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))?;
|
||||
let mut models = Vec::new();
|
||||
for row in data {
|
||||
let facts = &row["codewhale"];
|
||||
if facts["usable"].as_bool() != Some(true) {
|
||||
continue;
|
||||
}
|
||||
if facts["protocol"].as_str() != Some("chat-completions")
|
||||
|| facts["billingMode"].as_str() != Some("byok_external")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = model_text(row, "id")?;
|
||||
let provider = model_text(facts, "provider")?;
|
||||
let model = model_text(facts, "model")?;
|
||||
if id != format!("{provider}/{model}") {
|
||||
bail!("The Codewhale account model catalog was invalid");
|
||||
}
|
||||
models.push(AccountModel {
|
||||
id,
|
||||
label: facts["label"]
|
||||
.as_str()
|
||||
.map(|label| printable_max(label, 200))
|
||||
.filter(|label| !label.is_empty())
|
||||
.unwrap_or_else(|| model.clone()),
|
||||
provider,
|
||||
model,
|
||||
protocol: model_text(facts, "protocol")?,
|
||||
billing_mode: model_text(facts, "billingMode")?,
|
||||
is_default: facts["default"].as_bool() == Some(true),
|
||||
});
|
||||
}
|
||||
let stored = snapshot
|
||||
.load()?
|
||||
.ok_or_else(|| anyhow!("The signed-in account changed. Refresh and try again."))?;
|
||||
let account_id = stored
|
||||
.bundle
|
||||
.user
|
||||
.as_ref()
|
||||
.map(|user| user.id.clone())
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
|
||||
let session = stored
|
||||
.bundle
|
||||
.session
|
||||
.as_ref()
|
||||
.filter(|session| !session.id.is_empty())
|
||||
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
|
||||
let session_id = session.id.clone();
|
||||
let access_expires = DateTime::parse_from_rfc3339(&session.expires_at)
|
||||
.context("The account session expiry was invalid")?
|
||||
.timestamp();
|
||||
let refresh_expires = DateTime::parse_from_rfc3339(&session.refresh_expires_at)
|
||||
.context("The account session expiry was invalid")?
|
||||
.timestamp();
|
||||
let default_model = catalog["codewhale"]["defaultModel"]
|
||||
.as_str()
|
||||
.filter(|id| models.iter().any(|model| model.id == *id))
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| model.is_default)
|
||||
.map(|model| model.id.clone())
|
||||
});
|
||||
let mut result = AccountModels {
|
||||
api_base: format!("{}/v1", self.api_base),
|
||||
account_id: account_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
expires_at: 0,
|
||||
default_model,
|
||||
models,
|
||||
credential: None,
|
||||
snapshot,
|
||||
};
|
||||
if result.models.is_empty() {
|
||||
let current = self.store.with_transaction(|transaction| {
|
||||
Ok::<_, anyhow::Error>(transaction.matches(&result.snapshot))
|
||||
})?;
|
||||
if !current {
|
||||
bail!("The signed-in account changed. Refresh and try again.");
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
bail!("Codewhale account model connection cancelled");
|
||||
}
|
||||
let device_id = self.store.device_id()?;
|
||||
let mut issued_id: Option<String> = None;
|
||||
let connected = self.store.with_transaction(|transaction| {
|
||||
if !transaction.matches(&result.snapshot) || access_expires <= Utc::now().timestamp() {
|
||||
bail!("The signed-in account changed. Refresh and try again.");
|
||||
}
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
bail!("Codewhale account model connection cancelled");
|
||||
}
|
||||
let current = transaction.snapshot();
|
||||
if let Some(access) = current.device_access()? {
|
||||
if access.device_id != device_id {
|
||||
bail!("The Codewhale device connection changed. Reconnect your account.");
|
||||
}
|
||||
machine::MachineKey::parse(&access.credential)?;
|
||||
if access.expires_at > Utc::now().timestamp() {
|
||||
let checked = self.transport.execute(CloudRequest {
|
||||
method: HttpMethod::Get,
|
||||
path: "/v1/models".into(),
|
||||
bearer: Some(access.credential.clone()),
|
||||
body: None,
|
||||
})?;
|
||||
match checked.status {
|
||||
200 => return Ok((access, current)),
|
||||
401 => {}
|
||||
_ => return Err(response_error(&checked)),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
bail!("Codewhale account model connection cancelled");
|
||||
}
|
||||
let response = self.transport.execute(CloudRequest {
|
||||
method: HttpMethod::Post,
|
||||
path: "/api/account/device-link".into(),
|
||||
bearer: Some(stored.bundle.access_token.clone()),
|
||||
body: Some(serde_json::to_vec(&serde_json::json!({
|
||||
"deviceId": device_id,
|
||||
"deviceName": "Codewhale",
|
||||
"expectedAccountId": account_id,
|
||||
}))?),
|
||||
})?;
|
||||
let linked: LinkedDevice = expect_json(response, &[201])?;
|
||||
if linked.api_key.id.len() == 24
|
||||
&& linked
|
||||
.api_key
|
||||
.id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
issued_id = Some(linked.api_key.id.clone());
|
||||
}
|
||||
let key = machine::MachineKey::parse(&linked.secret)?;
|
||||
let expires_at = DateTime::parse_from_rfc3339(&linked.api_key.expires_at)
|
||||
.context("The account device expiry was invalid")?
|
||||
.timestamp();
|
||||
if !linked.session_bound
|
||||
|| linked.api_key.scopes.len() != 1
|
||||
|| linked.api_key.scopes[0] != "models:infer"
|
||||
|| linked.api_key.revoked_at.is_some()
|
||||
|| linked.api_key.context.kind != "device"
|
||||
|| linked.api_key.context.device_id != device_id
|
||||
|| linked.api_key.context.session_id != session_id
|
||||
|| key.head().strip_prefix("cwc_key_") != Some(linked.api_key.id.as_str())
|
||||
|| expires_at <= Utc::now().timestamp()
|
||||
|| expires_at > refresh_expires
|
||||
{
|
||||
bail!("The Codewhale device connection could not be verified");
|
||||
}
|
||||
let access = AccountDeviceAccess {
|
||||
device_id: device_id.clone(),
|
||||
credential: linked.secret,
|
||||
api_key_id: linked.api_key.id,
|
||||
account_id: account_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
expires_at,
|
||||
};
|
||||
transaction.set_device_access(access.clone())?;
|
||||
Ok((access, transaction.snapshot()))
|
||||
});
|
||||
let (access, snapshot) = match connected {
|
||||
Ok(connected) => connected,
|
||||
Err(error) => {
|
||||
if let Some(id) = issued_id {
|
||||
let _ = self.transport.execute(CloudRequest {
|
||||
method: HttpMethod::Delete,
|
||||
path: format!("/api/account/api-keys/{id}"),
|
||||
bearer: Some(stored.bundle.access_token),
|
||||
body: None,
|
||||
});
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
result.expires_at = access.expires_at.min(access_expires);
|
||||
result.credential = Some(access.credential);
|
||||
result.snapshot = snapshot;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn start_login(&self, timeout: Duration) -> Result<AccountLogin> {
|
||||
self.start_login_with_cancel(timeout, Arc::new(AtomicBool::new(false)))
|
||||
}
|
||||
|
||||
pub fn start_login_with_cancel(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
) -> Result<AccountLogin> {
|
||||
if timeout.is_zero() || timeout > Duration::from_secs(MAX_LOGIN_TIMEOUT_SECONDS) {
|
||||
bail!("Invalid Codewhale account login timeout");
|
||||
}
|
||||
let expected = self.store.with_transaction(|transaction| {
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
bail!("Codewhale account login cancelled");
|
||||
}
|
||||
Ok::<_, anyhow::Error>(transaction.renew_revision()?)
|
||||
})?;
|
||||
let started = Instant::now();
|
||||
if cancelled.load(Ordering::Acquire) {
|
||||
bail!("Codewhale account login cancelled");
|
||||
}
|
||||
let device = self.client().start_device()?;
|
||||
validate_device_code(&device.device_code)?;
|
||||
validate_user_code(&device.user_code)?;
|
||||
let verification_uri = validate_verification_url(
|
||||
&device.verification_uri,
|
||||
&self.api_base,
|
||||
&device.user_code,
|
||||
false,
|
||||
)?;
|
||||
let verification_uri_complete = validate_verification_url(
|
||||
&device.verification_uri_complete,
|
||||
&self.api_base,
|
||||
&device.user_code,
|
||||
true,
|
||||
)?;
|
||||
let lifetime = timeout.min(Duration::from_secs(
|
||||
device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS),
|
||||
));
|
||||
let interval = if device.interval == 0 {
|
||||
codewhale_config::device_code::DEFAULT_POLL_INTERVAL_SECS
|
||||
} else {
|
||||
device.interval.min(10)
|
||||
};
|
||||
Ok(AccountLogin {
|
||||
client: self.clone(),
|
||||
expected,
|
||||
prompt: AccountLoginPrompt {
|
||||
user_code: device.user_code.clone(),
|
||||
verification_uri,
|
||||
verification_uri_complete,
|
||||
expires_in: lifetime.as_secs(),
|
||||
interval,
|
||||
},
|
||||
device,
|
||||
cancelled: AccountLoginCancellation(cancelled),
|
||||
deadline: started + lifetime,
|
||||
next_poll: started,
|
||||
pending_bundle: None,
|
||||
finished: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn model_text(value: &serde_json::Value, key: &str) -> Result<String> {
|
||||
value[key]
|
||||
.as_str()
|
||||
.filter(|text| {
|
||||
!text.is_empty()
|
||||
&& text.len() <= 512
|
||||
&& !text.chars().any(char::is_control)
|
||||
&& !text.chars().any(char::is_whitespace)
|
||||
})
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LinkedDevice {
|
||||
secret: String,
|
||||
api_key: LinkedDeviceKey,
|
||||
session_bound: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LinkedDeviceKey {
|
||||
id: String,
|
||||
scopes: Vec<String>,
|
||||
expires_at: String,
|
||||
revoked_at: Option<String>,
|
||||
context: LinkedDeviceContext,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LinkedDeviceContext {
|
||||
kind: String,
|
||||
device_id: String,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountLoginPrompt {
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub verification_uri_complete: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AccountLoginCancellation(Arc<AtomicBool>);
|
||||
|
||||
impl AccountLoginCancellation {
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AccountLoginPoll {
|
||||
Pending { retry_after: Duration },
|
||||
Complete { user: CloudUser },
|
||||
Cancelled,
|
||||
Expired,
|
||||
}
|
||||
|
||||
pub struct AccountLogin {
|
||||
client: AccountClient,
|
||||
expected: AccountSessionSnapshot,
|
||||
device: DeviceStart,
|
||||
prompt: AccountLoginPrompt,
|
||||
cancelled: AccountLoginCancellation,
|
||||
deadline: Instant,
|
||||
next_poll: Instant,
|
||||
pending_bundle: Option<AuthBundle>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl AccountLogin {
|
||||
pub fn prompt(&self) -> &AccountLoginPrompt {
|
||||
&self.prompt
|
||||
}
|
||||
|
||||
pub fn cancellation(&self) -> AccountLoginCancellation {
|
||||
self.cancelled.clone()
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> Result<AccountLoginPoll> {
|
||||
let result = self.poll_once();
|
||||
if !matches!(result, Ok(AccountLoginPoll::Pending { .. })) {
|
||||
self.finished = true;
|
||||
}
|
||||
if matches!(
|
||||
result,
|
||||
Ok(AccountLoginPoll::Cancelled | AccountLoginPoll::Expired)
|
||||
) {
|
||||
self.revoke_pending();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn revoke_pending(&mut self) {
|
||||
if let Some(bundle) = self.pending_bundle.take() {
|
||||
self.client.revoke_session_later(None, bundle);
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_once(&mut self) -> Result<AccountLoginPoll> {
|
||||
if self.finished || self.cancelled.0.load(Ordering::Acquire) {
|
||||
return Ok(AccountLoginPoll::Cancelled);
|
||||
}
|
||||
let now = Instant::now();
|
||||
if now >= self.deadline {
|
||||
return Ok(AccountLoginPoll::Expired);
|
||||
}
|
||||
if now < self.next_poll {
|
||||
return Ok(AccountLoginPoll::Pending {
|
||||
retry_after: (self.next_poll - now).min(self.deadline - now),
|
||||
});
|
||||
}
|
||||
if !self.client.store.with_transaction(|transaction| {
|
||||
Ok::<_, anyhow::Error>(transaction.matches(&self.expected))
|
||||
})? {
|
||||
return Ok(AccountLoginPoll::Cancelled);
|
||||
}
|
||||
if let Some(bundle) = self.pending_bundle.as_mut() {
|
||||
if bundle.user.is_none() {
|
||||
let response = self.client.transport.execute(CloudRequest {
|
||||
method: HttpMethod::Get,
|
||||
path: "/api/me".into(),
|
||||
bearer: Some(bundle.access_token.clone()),
|
||||
body: None,
|
||||
})?;
|
||||
bundle.user = Some(expect_json::<MeResponse>(response, &[200])?.user);
|
||||
}
|
||||
} else {
|
||||
match self.client.client().poll_device_once(&self.device)? {
|
||||
DevicePollOutcome::Complete(bundle) => self.pending_bundle = Some(bundle),
|
||||
DevicePollOutcome::Pending | DevicePollOutcome::SlowDown { .. } => {
|
||||
self.next_poll = Instant::now() + Duration::from_secs(self.prompt.interval);
|
||||
return Ok(AccountLoginPoll::Pending {
|
||||
retry_after: self
|
||||
.next_poll
|
||||
.min(self.deadline)
|
||||
.saturating_duration_since(Instant::now()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let bundle = self
|
||||
.pending_bundle
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
|
||||
if bundle.user.is_none() {
|
||||
return Ok(AccountLoginPoll::Pending {
|
||||
retry_after: Duration::ZERO,
|
||||
});
|
||||
}
|
||||
let Some(user) = bundle
|
||||
.user
|
||||
.as_ref()
|
||||
.filter(|user| !user.id.trim().is_empty())
|
||||
else {
|
||||
bail!("The Codewhale service returned an account without an ID");
|
||||
};
|
||||
let user = user.clone();
|
||||
validate_auth_bundle(bundle)?;
|
||||
if Instant::now() >= self.deadline {
|
||||
return Ok(AccountLoginPoll::Expired);
|
||||
}
|
||||
let bundle = self
|
||||
.pending_bundle
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
|
||||
let committed = self.client.store.with_transaction(|transaction| {
|
||||
if self.cancelled.0.load(Ordering::Acquire)
|
||||
|| !transaction.matches(&self.expected)
|
||||
|| Instant::now() >= self.deadline
|
||||
{
|
||||
return Ok::<_, anyhow::Error>(false);
|
||||
}
|
||||
transaction.replace(bundle.clone())?;
|
||||
Ok(true)
|
||||
})?;
|
||||
if !committed {
|
||||
self.client.revoke_session_later(None, bundle);
|
||||
return Ok(AccountLoginPoll::Cancelled);
|
||||
}
|
||||
self.client
|
||||
.revoke_session_later(Some(self.expected.clone()), bundle);
|
||||
Ok(AccountLoginPoll::Complete { user })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AccountLogin {
|
||||
fn drop(&mut self) {
|
||||
if !self.finished {
|
||||
self.cancelled.cancel();
|
||||
}
|
||||
self.revoke_pending();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ workspace = true
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
|
||||
codewhale-config = { path = "../config", version = "0.10.2" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.2" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.10.2" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.10.2" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -20,8 +20,8 @@
|
||||
//! ledger / executor / worker_runtime / roster / profile / identity /
|
||||
//! members, and nothing from tools.
|
||||
|
||||
use codewhale_protocol::fleet::{FleetEffectivePermissions, migrate_legacy_role_token};
|
||||
use codewhale_protocol::fleet::{PermissionCeiling, ShellCeiling};
|
||||
use codewhale_protocol::fleet::FleetEffectivePermissions;
|
||||
use codewhale_workflow::{PermissionCeiling, ShellCeiling};
|
||||
|
||||
use crate::worker_profile::{ShellPolicy, ToolScope, WorkerRuntimeProfile};
|
||||
|
||||
@@ -40,7 +40,7 @@ pub const FLEET_ROLE_SCHEMA_VALUES: [&str; 8] = [
|
||||
"custom",
|
||||
];
|
||||
|
||||
/// Human-readable hint listing every token [`FleetRole::parse`] accepts,
|
||||
/// Human-readable hint listing every token [`FleetRole::from_str`] accepts,
|
||||
/// for spawn-time error messages. Keep in sync with
|
||||
/// [`migrate_legacy_role_token`] and the `from_str` match arms; those two are
|
||||
/// the only role parser (#2649 was a second table in the spawn tool drifting
|
||||
@@ -114,18 +114,38 @@ impl<'de> serde::Deserialize<'de> for FleetRole {
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::parse(&raw)
|
||||
Self::from_str(&raw)
|
||||
.ok_or_else(|| serde::de::Error::unknown_variant(&raw, &FLEET_ROLE_SCHEMA_VALUES))
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit boundary migration for pre-Fleet serialized role tokens.
|
||||
///
|
||||
/// Call this only at load / parse edges. Runtime code must use Fleet role
|
||||
/// names via [`FleetRole::as_str`]. Returns `None` for tokens that are
|
||||
/// already canonical or unknown — callers should prefer [`FleetRole::from_str`]
|
||||
/// for full acceptance (canonical + legacy).
|
||||
#[must_use]
|
||||
pub fn migrate_legacy_role_token(token: &str) -> Option<&'static str> {
|
||||
match token.trim().to_ascii_lowercase().as_str() {
|
||||
"worker" | "general-purpose" | "general_purpose" | "default" => Some("general"),
|
||||
"scout" | "exploration" | "explorer" => Some("explore"),
|
||||
"plan" | "planning" | "awaiter" => Some("planner"),
|
||||
"review" | "code-review" | "code_review" => Some("reviewer"),
|
||||
"builder" | "implementer" | "implementation" => Some("implement"),
|
||||
"verifier" | "verify" | "verification" | "validator" | "tester" => Some("test"),
|
||||
"consultant" | "oracle" => Some("advisor"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl FleetRole {
|
||||
/// Parse a Fleet role from user input or a serialized boundary.
|
||||
///
|
||||
/// Accepts Fleet role names and, at this parse boundary only, legacy
|
||||
/// aliases (`scout` → explore, `plan` → planner, …).
|
||||
#[must_use]
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
let normalized = s.trim().to_ascii_lowercase();
|
||||
// Boundary migration first, then canonical Fleet names.
|
||||
let token = migrate_legacy_role_token(&normalized).unwrap_or(normalized.as_str());
|
||||
@@ -202,7 +222,7 @@ impl FleetRole {
|
||||
/// anything else passes through trimmed.
|
||||
#[must_use]
|
||||
pub fn public_role_label(token: &str) -> String {
|
||||
FleetRole::parse(token).map_or_else(
|
||||
FleetRole::from_str(token).map_or_else(
|
||||
|| token.trim().to_string(),
|
||||
|role| role.as_str().to_string(),
|
||||
)
|
||||
@@ -268,7 +288,7 @@ pub const NETWORK_TOOL_DENYLIST: &[&str] = &[
|
||||
// contract was considered and rejected: `rlm_open` chooses its source from
|
||||
// *input fields* (`file_path` / `content` / `url` / `session_object`), not
|
||||
// from the action name, and the action-policy seam
|
||||
// (`codewhale_execpolicy::canonical_action`) resolves names, not field shapes —
|
||||
// (`crate::tools::canonical_action`) resolves names, not field shapes —
|
||||
// it cannot prove a source is local before execution. So this fails closed.
|
||||
// A network-denied member loses `rlm` loading and evaluation entirely,
|
||||
// including the purely local `file_path` form, and keeps only the bounded
|
||||
@@ -639,7 +659,7 @@ impl ChildAuthority {
|
||||
/// model-facing spawn vocabulary.
|
||||
///
|
||||
/// Deliberately *not* folded into [`migrate_legacy_role_token`]. That table
|
||||
/// feeds [`FleetRole::parse`], which is also the closed vocabulary the
|
||||
/// feeds [`FleetRole::from_str`], which is also the closed vocabulary the
|
||||
/// `agent` tool validates its `type` against, and — decisively — the test the
|
||||
/// spawn parser uses to decide whether a `role` string is a posture alias or a
|
||||
/// roster profile key. Teaching `from_str` about `manager` or `smoke-runner`
|
||||
@@ -685,7 +705,7 @@ fn member_role_alias(token: &str) -> Option<FleetRole> {
|
||||
/// Resolution order, and nothing else:
|
||||
/// 1. an empty label means *unspecified*, which is the documented general
|
||||
/// default a Fleet task without a `role` has always had;
|
||||
/// 2. [`FleetRole::parse`] — the declared closed vocabulary plus its
|
||||
/// 2. [`FleetRole::from_str`] — the declared closed vocabulary plus its
|
||||
/// documented legacy aliases ([`VALID_ROLE_ALIASES`]);
|
||||
/// 3. `member_role_alias` — the org-chart/slot spellings above;
|
||||
/// 4. **fail closed.**
|
||||
@@ -703,7 +723,7 @@ pub fn runtime_role_for_member(role: &str) -> FleetRole {
|
||||
if token.is_empty() {
|
||||
return FleetRole::Worker;
|
||||
}
|
||||
FleetRole::parse(&token)
|
||||
FleetRole::from_str(&token)
|
||||
.or_else(|| member_role_alias(&token))
|
||||
.unwrap_or(FleetRole::Scout)
|
||||
}
|
||||
|
||||
@@ -463,7 +463,7 @@ impl WorkerRuntimeProfile {
|
||||
// wins via `derive_child`, same as every other role.
|
||||
reasoning_effort: matches!(role, FleetRole::Consultant).then(|| "high".to_string()),
|
||||
denied_tools: Vec::new(),
|
||||
max_spawn_depth: codewhale_protocol::fleet::DEFAULT_SPAWN_DEPTH,
|
||||
max_spawn_depth: codewhale_config::DEFAULT_SPAWN_DEPTH,
|
||||
spawn_depth: 0,
|
||||
wall_time_secs: None,
|
||||
wall_deadline_ms: None,
|
||||
@@ -579,69 +579,6 @@ impl Default for WorkerRuntimeProfile {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildActivationIdentity {
|
||||
pub owner_session_id: String,
|
||||
pub session_boot_id: String,
|
||||
pub public_child_id: String,
|
||||
pub child_id: String,
|
||||
pub run_id: String,
|
||||
pub generation: u32,
|
||||
pub initial_submission_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChildActivationDelivery {
|
||||
#[default]
|
||||
Parent,
|
||||
Caller,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FrozenChildConfiguration {
|
||||
pub persona: Option<String>,
|
||||
pub denied_tools: Vec<String>,
|
||||
pub delivery: ChildActivationDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildInitialAdmission {
|
||||
pub submission_id: String,
|
||||
pub turn_id: String,
|
||||
pub message_index: usize,
|
||||
pub prefix_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ChildActivationStopReason {
|
||||
Completed,
|
||||
Aborted,
|
||||
Error,
|
||||
MaxTokens,
|
||||
Refusal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildActivationOutput {
|
||||
pub transcript_message_index: usize,
|
||||
pub content_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildActivationManifest {
|
||||
pub identity: ChildActivationIdentity,
|
||||
pub configuration: Option<FrozenChildConfiguration>,
|
||||
pub admission: Option<ChildInitialAdmission>,
|
||||
#[serde(default)]
|
||||
pub output_captured: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stop_reason: Option<ChildActivationStopReason>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<ChildActivationOutput>,
|
||||
}
|
||||
|
||||
/// Unified pre-launch manifest for a child agent (#414).
|
||||
///
|
||||
/// Everything needed to provision, launch, and resume a child — prompt, role,
|
||||
@@ -649,8 +586,6 @@ pub struct ChildActivationManifest {
|
||||
/// from this single persisted record. No field is derived ad-hoc at launch time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ChildLaunchManifest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activation: Option<ChildActivationManifest>,
|
||||
pub owner_session: String,
|
||||
pub child_id: String,
|
||||
pub profile: WorkerRuntimeProfile,
|
||||
|
||||
@@ -11,5 +11,5 @@ description = "Model/provider registry and fallback strategy for Codewhale"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.10.3" }
|
||||
codewhale-config = { path = "../config", version = "0.10.2" }
|
||||
serde.workspace = true
|
||||
@@ -1131,7 +1131,7 @@ mod tests {
|
||||
|
||||
for (provider, id) in [
|
||||
(ProviderKind::Zai, "GLM-5.2"),
|
||||
(ProviderKind::Stepfun, "step-5-preview"),
|
||||
(ProviderKind::Stepfun, "step-3.7-flash"),
|
||||
(ProviderKind::Minimax, "MiniMax-M2.1"),
|
||||
(ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
|
||||
(ProviderKind::Openmodel, "deepseek-v4-flash"),
|
||||
@@ -1245,12 +1245,12 @@ mod tests {
|
||||
|
||||
let default = registry.resolve_ok(None, Some(ProviderKind::Xai));
|
||||
assert_eq!(default.resolved.provider, ProviderKind::Xai);
|
||||
assert_eq!(default.resolved.id, "grok-4.7");
|
||||
assert_eq!(default.resolved.id, "grok-4.6");
|
||||
assert!(default.used_fallback);
|
||||
|
||||
let alias = registry.resolve_ok(Some("grok"), Some(ProviderKind::Xai));
|
||||
assert_eq!(alias.resolved.provider, ProviderKind::Xai);
|
||||
assert_eq!(alias.resolved.id, "grok-4.7");
|
||||
assert_eq!(alias.resolved.id, "grok-4.6");
|
||||
assert!(!alias.used_fallback);
|
||||
|
||||
let fast = registry.resolve_ok(
|
||||
@@ -1268,12 +1268,12 @@ mod tests {
|
||||
|
||||
let default = registry.resolve_ok(None, Some(ProviderKind::Meta));
|
||||
assert_eq!(default.resolved.provider, ProviderKind::Meta);
|
||||
assert_eq!(default.resolved.id, "muse-spark-1.3");
|
||||
assert_eq!(default.resolved.id, "muse-spark-1.2");
|
||||
assert!(default.used_fallback);
|
||||
|
||||
let alias = registry.resolve_ok(Some("muse-spark"), Some(ProviderKind::Meta));
|
||||
assert_eq!(alias.resolved.provider, ProviderKind::Meta);
|
||||
assert_eq!(alias.resolved.id, "muse-spark-1.3");
|
||||
assert_eq!(alias.resolved.id, "muse-spark-1.2");
|
||||
assert!(!alias.used_fallback);
|
||||
assert_eq!(model_family("muse-spark-1.2"), ModelFamily::Meta);
|
||||
}
|
||||
@@ -1307,7 +1307,7 @@ mod tests {
|
||||
|
||||
let stepfun = registry.resolve_ok(None, Some(ProviderKind::Stepfun));
|
||||
assert_eq!(stepfun.resolved.provider, ProviderKind::Stepfun);
|
||||
assert_eq!(stepfun.resolved.id, "step-5-preview");
|
||||
assert_eq!(stepfun.resolved.id, "step-3.7-flash");
|
||||
|
||||
for (alias, expected) in [
|
||||
("minimax", "MiniMax-M3"),
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "codewhale-app-server-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT AND Apache-2.0"
|
||||
repository.workspace = true
|
||||
description = "Client wire contracts and schema exports for Codewhale App Server"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
schema = ["dep:schemars"]
|
||||
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
schemars = { version = "1.2.1", optional = true, features = ["derive", "preserve_order"] }
|
||||
|
||||
[dev-dependencies]
|
||||
jsonschema.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "codewhale-codex-contract"
|
||||
path = "src/bin/contract.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "codewhale-app-server-schema"
|
||||
path = "src/bin/export.rs"
|
||||
required-features = ["schema"]
|
||||
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 OpenAI
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,285 +0,0 @@
|
||||
# Codex App Server method inventory
|
||||
|
||||
Generated from `contract/codex-v2.json` (contract `codewhale-codex-app-server-v2` version 1) and the pinned Codex `rust-v0.166.0` export (commit `6ff1e1ca3b30fc8b914720681fcf5fe62e95f856`) by `cargo run -p codewhale-app-server-protocol --bin codewhale-codex-contract -- --write`. Edit the manifest, never this file; `tests/contract.rs` fails when they drift. The method sets are the unmodified export members under `tests/pinned/codex/`.
|
||||
|
||||
- **A**: required for 0.10.3 Codex parity.
|
||||
- **B**: service methods, required for 0.10.3.
|
||||
- **C**: out of scope, for the reason given.
|
||||
- **undecided**: the 0.10.3 parity bar does not settle it yet. The last column carries a recommended tier for the founder to confirm; until then it is handled exactly like Tier C.
|
||||
|
||||
Status: `implemented` means a Codex-shaped handler is on main (response goldens are not yet checked); `partial` means the Codex method name dispatches to the legacy shape; `missing` means no handler; `method-not-found` means the dispatcher answers `-32601`; `never-sent` means the server never emits it. The stable export is the scope: methods that exist only in the experimental export are not listed. A complete inventory or typed shape is not a handler: status, not tier, says what main dispatches.
|
||||
|
||||
## Dispatch rules
|
||||
|
||||
| Tier | Rule |
|
||||
| --- | --- |
|
||||
| A | selected for 0.10.3; the typed params/result in src/v2.rs are the wire contract |
|
||||
| B | selected for 0.10.3 (service methods); the typed params/result in src/v2.rs are the wire contract |
|
||||
| C | not selected; a client request answers methodNotFound (-32601); the server never sends a Tier C request or notification |
|
||||
| undecided | not selected until the founder decides; until then it is handled exactly like Tier C (methodNotFound, never sent), and the row keeps its recommendation |
|
||||
|
||||
## Client requests
|
||||
|
||||
| Method | Tier | Status | Basis | Recommended | Params | Result |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `initialize` | A | implemented | connection lifecycle (CW-7704; on main) | | `InitializeParams` | `InitializeResponse` |
|
||||
| `thread/start` | A | implemented | thread lifecycle | | `ThreadStartParams` | `ThreadStartResponse` |
|
||||
| `thread/resume` | A | partial | thread lifecycle | | `ThreadResumeParams` | `ThreadResumeResponse` |
|
||||
| `thread/fork` | A | partial | thread lifecycle | | `ThreadForkParams` | `ThreadForkResponse` |
|
||||
| `thread/archive` | A | partial | thread lifecycle | | `ThreadArchiveParams` | `ThreadArchiveResponse` |
|
||||
| `thread/delete` | B | missing | S9 services: thread delete, revert, shellCommand, compact | | `ThreadDeleteParams` | `ThreadDeleteResponse` |
|
||||
| `thread/unsubscribe` | A | missing | S5 subscriptions and paging | | `ThreadUnsubscribeParams` | `ThreadUnsubscribeResponse` |
|
||||
| `thread/name/set` | A | implemented | thread lifecycle | | `ThreadSetNameParams` | `ThreadSetNameResponse` |
|
||||
| `thread/goal/set` | A | partial | S8 goals | | `ThreadGoalSetParams` | `ThreadGoalSetResponse` |
|
||||
| `thread/goal/get` | A | partial | S8 goals | | `ThreadGoalGetParams` | `ThreadGoalGetResponse` |
|
||||
| `thread/goal/clear` | A | partial | S8 goals | | `ThreadGoalClearParams` | `ThreadGoalClearResponse` |
|
||||
| `thread/metadata/update` | undecided | missing | thread metadata beyond name/goal | B | ThreadMetadataUpdateParams (untyped) | ThreadMetadataUpdateResponse (untyped) |
|
||||
| `thread/attachment/add` | undecided | missing | Codex desktop attachments | C | ThreadAttachmentAddParams (untyped) | ThreadAttachmentAddResponse (untyped) |
|
||||
| `thread/attachment/list` | undecided | missing | Codex desktop attachments | C | ThreadAttachmentListParams (untyped) | ThreadAttachmentListResponse (untyped) |
|
||||
| `thread/attachmentOwner/list` | undecided | missing | Codex desktop attachments | C | ThreadAttachmentOwnerListParams (untyped) | ThreadAttachmentOwnerListResponse (untyped) |
|
||||
| `thread/attachment/remove` | undecided | missing | Codex desktop attachments | C | ThreadAttachmentRemoveParams (untyped) | ThreadAttachmentRemoveResponse (untyped) |
|
||||
| `thread/section/move` | undecided | missing | Codex desktop sidebar sections; Codewhale projects are CW-7878 | C | ThreadSectionMoveParams (untyped) | ThreadSectionMoveResponse (untyped) |
|
||||
| `thread/unarchive` | A | partial | thread lifecycle | | `ThreadUnarchiveParams` | `ThreadUnarchiveResponse` |
|
||||
| `thread/compact/start` | B | missing | S9 services: thread delete, revert, shellCommand, compact | | `ThreadCompactStartParams` | `ThreadCompactStartResponse` |
|
||||
| `thread/shellCommand` | B | missing | S9 services: thread delete, revert, shellCommand, compact | | `ThreadShellCommandParams` | `ThreadShellCommandResponse` |
|
||||
| `thread/approveGuardianDeniedAction` | undecided | missing | Codex Guardian auto-review override; Codewhale has its own Auto-Review posture | C | ThreadApproveGuardianDeniedActionParams (untyped) | ThreadApproveGuardianDeniedActionResponse (untyped) |
|
||||
| `thread/revert` | B | missing | S9 services: thread delete, revert, shellCommand, compact | | `ThreadRevertParams` | `ThreadRevertResponse` |
|
||||
| `thread/list` | A | implemented | thread lifecycle | | `ThreadListParams` | `ThreadListResponse` |
|
||||
| `threadSection/list` | undecided | missing | Codex desktop sidebar sections; Codewhale projects are CW-7878 | C | ThreadSectionListParams (untyped) | ThreadSectionListResponse (untyped) |
|
||||
| `threadSection/create` | undecided | missing | Codex desktop sidebar sections; Codewhale projects are CW-7878 | C | ThreadSectionCreateParams (untyped) | ThreadSectionCreateResponse (untyped) |
|
||||
| `threadSection/update` | undecided | missing | Codex desktop sidebar sections; Codewhale projects are CW-7878 | C | ThreadSectionUpdateParams (untyped) | ThreadSectionUpdateResponse (untyped) |
|
||||
| `threadSection/delete` | undecided | missing | Codex desktop sidebar sections; Codewhale projects are CW-7878 | C | ThreadSectionDeleteParams (untyped) | ThreadSectionDeleteResponse (untyped) |
|
||||
| `thread/loaded/list` | A | implemented | thread lifecycle | | `ThreadLoadedListParams` | `ThreadLoadedListResponse` |
|
||||
| `thread/read` | A | implemented | thread lifecycle | | `ThreadReadParams` | `ThreadReadResponse` |
|
||||
| `thread/turns/list` | A | missing | S5 subscriptions and paging | | `ThreadTurnsListParams` | `ThreadTurnsListResponse` |
|
||||
| `thread/items/list` | A | missing | S5 subscriptions and paging | | `ThreadItemsListParams` | `ThreadItemsListResponse` |
|
||||
| `thread/inject_items` | undecided | missing | raw history injection | C | ThreadInjectItemsParams (untyped) | ThreadInjectItemsResponse (untyped) |
|
||||
| `skills/list` | B | missing | S9 services: skills | | `SkillsListParams` | `SkillsListResponse` |
|
||||
| `skills/extraRoots/set` | undecided | missing | skills configuration writes | B | SkillsExtraRootsSetParams (untyped) | SkillsExtraRootsSetResponse (untyped) |
|
||||
| `hooks/list` | B | missing | S9 services: hooks | | `HooksListParams` | `HooksListResponse` |
|
||||
| `marketplace/add` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | MarketplaceAddParams (untyped) | MarketplaceAddResponse (untyped) |
|
||||
| `marketplace/remove` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | MarketplaceRemoveParams (untyped) | MarketplaceRemoveResponse (untyped) |
|
||||
| `marketplace/upgrade` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | MarketplaceUpgradeParams (untyped) | MarketplaceUpgradeResponse (untyped) |
|
||||
| `plugin/list` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | PluginListParams (untyped) | PluginListResponse (untyped) |
|
||||
| `plugin/installed` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | PluginInstalledParams (untyped) | PluginInstalledResponse (untyped) |
|
||||
| `plugin/reconcile` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | PluginReconcileParams (untyped) | PluginReconcileResponse (untyped) |
|
||||
| `plugin/read` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | PluginReadParams (untyped) | PluginReadResponse (untyped) |
|
||||
| `plugin/skill/read` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | PluginSkillReadParams (untyped) | PluginSkillReadResponse (untyped) |
|
||||
| `plugin/share/save` | C | method-not-found | OpenAI-hosted plugin sharing; no Codewhale service | | PluginShareSaveParams (untyped) | PluginShareSaveResponse (untyped) |
|
||||
| `plugin/share/updateTargets` | C | method-not-found | OpenAI-hosted plugin sharing; no Codewhale service | | PluginShareUpdateTargetsParams (untyped) | PluginShareUpdateTargetsResponse (untyped) |
|
||||
| `plugin/share/list` | C | method-not-found | OpenAI-hosted plugin sharing; no Codewhale service | | PluginShareListParams (untyped) | PluginShareListResponse (untyped) |
|
||||
| `plugin/share/checkout` | C | method-not-found | OpenAI-hosted plugin sharing; no Codewhale service | | PluginShareCheckoutParams (untyped) | PluginShareCheckoutResponse (untyped) |
|
||||
| `plugin/share/delete` | C | method-not-found | OpenAI-hosted plugin sharing; no Codewhale service | | PluginShareDeleteParams (untyped) | PluginShareDeleteResponse (untyped) |
|
||||
| `app/read` | C | method-not-found | ChatGPT Apps connectors; no Codewhale service | | AppsReadParams (untyped) | AppsReadResponse (untyped) |
|
||||
| `app/list` | C | method-not-found | ChatGPT Apps connectors; no Codewhale service | | AppsListParams (untyped) | AppsListResponse (untyped) |
|
||||
| `app/installed` | C | method-not-found | ChatGPT Apps connectors; no Codewhale service | | AppsInstalledParams (untyped) | AppsInstalledResponse (untyped) |
|
||||
| `fs/readFile` | B | missing | S9 services: fs | | `FsReadFileParams` | `FsReadFileResponse` |
|
||||
| `fs/writeFile` | B | missing | S9 services: fs | | `FsWriteFileParams` | `FsWriteFileResponse` |
|
||||
| `fs/createDirectory` | B | missing | S9 services: fs | | `FsCreateDirectoryParams` | `FsCreateDirectoryResponse` |
|
||||
| `fs/getMetadata` | B | missing | S9 services: fs | | `FsGetMetadataParams` | `FsGetMetadataResponse` |
|
||||
| `fs/readDirectory` | B | missing | S9 services: fs | | `FsReadDirectoryParams` | `FsReadDirectoryResponse` |
|
||||
| `fs/remove` | B | missing | S9 services: fs | | `FsRemoveParams` | `FsRemoveResponse` |
|
||||
| `fs/copy` | B | missing | S9 services: fs | | `FsCopyParams` | `FsCopyResponse` |
|
||||
| `fs/watch` | B | missing | S9 services: fs | | `FsWatchParams` | `FsWatchResponse` |
|
||||
| `fs/unwatch` | B | missing | S9 services: fs | | `FsUnwatchParams` | `FsUnwatchResponse` |
|
||||
| `skills/config/write` | undecided | missing | skills configuration writes | B | SkillsConfigWriteParams (untyped) | SkillsConfigWriteResponse (untyped) |
|
||||
| `plugin/install` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | PluginInstallParams (untyped) | PluginInstallResponse (untyped) |
|
||||
| `plugin/uninstall` | undecided | missing | Codex plugin marketplace; Codewhale plugins are DSH (CW-7051) | C | PluginUninstallParams (untyped) | PluginUninstallResponse (untyped) |
|
||||
| `turn/start` | A | missing | S4a full-tool turns | | `TurnStartParams` | `TurnStartResponse` |
|
||||
| `turn/steer` | A | missing | S4a full-tool turns | | `TurnSteerParams` | `TurnSteerResponse` |
|
||||
| `turn/interrupt` | A | missing | S4a full-tool turns | | `TurnInterruptParams` | `TurnInterruptResponse` |
|
||||
| `review/start` | B | missing | S9 services: review | | `ReviewStartParams` | `ReviewStartResponse` |
|
||||
| `model/list` | B | missing | S9 services: models | | `ModelListParams` | `ModelListResponse` |
|
||||
| `account/gatewayOAuth/read` | C | method-not-found | OpenAI gateway OAuth; no Codewhale service | | | |
|
||||
| `account/gatewayOAuth/login` | C | method-not-found | OpenAI gateway OAuth; no Codewhale service | | | |
|
||||
| `account/gatewayOAuth/cancel` | C | method-not-found | OpenAI gateway OAuth; no Codewhale service | | | |
|
||||
| `modelProvider/capabilities/read` | undecided | missing | provider, feature and permission catalogs | B | ModelProviderCapabilitiesReadParams (untyped) | ModelProviderCapabilitiesReadResponse (untyped) |
|
||||
| `experimentalFeature/list` | undecided | missing | Codex experimental feature flags | C | ExperimentalFeatureListParams (untyped) | ExperimentalFeatureListResponse (untyped) |
|
||||
| `permissionProfile/list` | undecided | missing | provider, feature and permission catalogs | B | PermissionProfileListParams (untyped) | PermissionProfileListResponse (untyped) |
|
||||
| `experimentalFeature/enablement/set` | undecided | missing | Codex experimental feature flags | C | ExperimentalFeatureEnablementSetParams (untyped) | ExperimentalFeatureEnablementSetResponse (untyped) |
|
||||
| `mcpServer/oauth/login` | undecided | missing | MCP actions beyond status | B | McpServerOauthLoginParams (untyped) | McpServerOauthLoginResponse (untyped) |
|
||||
| `config/mcpServer/reload` | undecided | missing | MCP actions beyond status | B | | |
|
||||
| `mcpServerStatus/list` | B | missing | S9 services: MCP status | | `ListMcpServerStatusParams` | `ListMcpServerStatusResponse` |
|
||||
| `mcpServer/resource/read` | undecided | missing | MCP actions beyond status | B | McpResourceReadParams (untyped) | McpResourceReadResponse (untyped) |
|
||||
| `mcpServer/tool/call` | undecided | missing | MCP actions beyond status | B | McpServerToolCallParams (untyped) | McpServerToolCallResponse (untyped) |
|
||||
| `windowsSandbox/setupStart` | undecided | missing | Codex Windows sandbox setup; Codewhale has its own AppContainer sandbox | C | WindowsSandboxSetupStartParams (untyped) | WindowsSandboxSetupStartResponse (untyped) |
|
||||
| `windowsSandbox/readiness` | undecided | missing | Codex Windows sandbox setup; Codewhale has its own AppContainer sandbox | C | | |
|
||||
| `account/login/start` | undecided | missing | OpenAI account sign-in and usage; Codewhale signs in through /auth | C | LoginAccountParams (untyped) | LoginAccountResponse (untyped) |
|
||||
| `account/login/cancel` | undecided | missing | OpenAI account sign-in and usage; Codewhale signs in through /auth | C | CancelLoginAccountParams (untyped) | CancelLoginAccountResponse (untyped) |
|
||||
| `account/logout` | undecided | missing | OpenAI account sign-in and usage; Codewhale signs in through /auth | C | | |
|
||||
| `account/rateLimits/read` | undecided | missing | OpenAI account sign-in and usage; Codewhale signs in through /auth | C | | |
|
||||
| `account/rateLimitResetCredit/consume` | C | method-not-found | OpenAI billing and workspace messaging; no Codewhale service | | ConsumeAccountRateLimitResetCreditParams (untyped) | ConsumeAccountRateLimitResetCreditResponse (untyped) |
|
||||
| `account/usage/read` | undecided | missing | OpenAI account sign-in and usage; Codewhale signs in through /auth | C | | |
|
||||
| `account/workspaceMessages/read` | C | method-not-found | OpenAI billing and workspace messaging; no Codewhale service | | | |
|
||||
| `account/sendAddCreditsNudgeEmail` | C | method-not-found | OpenAI billing and workspace messaging; no Codewhale service | | SendAddCreditsNudgeEmailParams (untyped) | SendAddCreditsNudgeEmailResponse (untyped) |
|
||||
| `feedback/upload` | C | method-not-found | uploads feedback to OpenAI; no Codewhale service | | FeedbackUploadParams (untyped) | FeedbackUploadResponse (untyped) |
|
||||
| `feedback/save` | C | method-not-found | uploads feedback to OpenAI; no Codewhale service | | FeedbackSaveParams (untyped) | FeedbackSaveResponse (untyped) |
|
||||
| `command/exec` | B | missing | S9 services: command/exec | | `CommandExecParams` | `CommandExecResponse` |
|
||||
| `command/exec/write` | B | missing | S9 services: command/exec | | `CommandExecWriteParams` | `CommandExecWriteResponse` |
|
||||
| `command/exec/terminate` | B | missing | S9 services: command/exec | | `CommandExecTerminateParams` | `CommandExecTerminateResponse` |
|
||||
| `command/exec/resize` | B | missing | S9 services: command/exec | | `CommandExecResizeParams` | `CommandExecResizeResponse` |
|
||||
| `config/read` | B | missing | S9 services: config | | `ConfigReadParams` | `ConfigReadResponse` |
|
||||
| `externalAgentConfig/detect` | undecided | missing | import config from other agents; Codewhale external agents are CW-7879 | C | ExternalAgentConfigDetectParams (untyped) | ExternalAgentConfigDetectResponse (untyped) |
|
||||
| `externalAgentConfig/import` | undecided | missing | import config from other agents; Codewhale external agents are CW-7879 | C | ExternalAgentConfigImportParams (untyped) | ExternalAgentConfigImportResponse (untyped) |
|
||||
| `externalAgentConfig/import/recordHistory` | undecided | missing | import config from other agents; Codewhale external agents are CW-7879 | C | ExternalAgentConfigImportHistoryRecordParams (untyped) | ExternalAgentConfigImportHistoryRecordResponse (untyped) |
|
||||
| `externalAgentConfig/import/readHistories` | undecided | missing | import config from other agents; Codewhale external agents are CW-7879 | C | | |
|
||||
| `config/value/write` | B | missing | S9 services: config | | `ConfigValueWriteParams` | `ConfigWriteResponse` |
|
||||
| `config/batchWrite` | B | missing | S9 services: config | | `ConfigBatchWriteParams` | `ConfigWriteResponse` |
|
||||
| `configRequirements/read` | undecided | missing | managed config requirements | B | | |
|
||||
| `account/read` | undecided | missing | OpenAI account sign-in and usage; Codewhale signs in through /auth | C | GetAccountParams (untyped) | GetAccountResponse (untyped) |
|
||||
| `getConversationSummary` | C | method-not-found | deprecated v1 API (upstream marks it DEPRECATED; TypeScript-only in the export) | | GetConversationSummaryParams (untyped) | GetConversationSummaryResponse (untyped) |
|
||||
| `gitDiffToRemote` | C | method-not-found | deprecated v1 API (upstream marks it DEPRECATED; TypeScript-only in the export) | | GitDiffToRemoteParams (untyped) | GitDiffToRemoteResponse (untyped) |
|
||||
| `getAuthStatus` | C | method-not-found | deprecated v1 API (upstream marks it DEPRECATED; TypeScript-only in the export) | | GetAuthStatusParams (untyped) | GetAuthStatusResponse (untyped) |
|
||||
| `fuzzyFileSearch` | undecided | missing | file search | B | FuzzyFileSearchParams (untyped) | FuzzyFileSearchResponse (untyped) |
|
||||
|
||||
## Client notifications
|
||||
|
||||
| Method | Tier | Status | Basis | Recommended | Params | Result |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `initialized` | A | implemented | connection lifecycle (CW-7704; on main) | | | |
|
||||
|
||||
## Server requests
|
||||
|
||||
| Method | Tier | Status | Basis | Recommended | Params | Result |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `item/commandExecution/requestApproval` | A | missing | S6 interactions (CW-7704: bidirectional approvals, input, client tools) | | `CommandExecutionRequestApprovalParams` | `CommandExecutionRequestApprovalResponse` |
|
||||
| `item/fileChange/requestApproval` | A | missing | S6 interactions (CW-7704: bidirectional approvals, input, client tools) | | `FileChangeRequestApprovalParams` | `FileChangeRequestApprovalResponse` |
|
||||
| `item/tool/requestUserInput` | A | missing | S6 interactions (CW-7704: bidirectional approvals, input, client tools) | | `ToolRequestUserInputParams` | `ToolRequestUserInputResponse` |
|
||||
| `mcpServer/elicitation/request` | undecided | missing | MCP elicitation | A | McpServerElicitationRequestParams (untyped) | McpServerElicitationRequestResponse (untyped) |
|
||||
| `item/permissions/requestApproval` | A | missing | S6 interactions (CW-7704: bidirectional approvals, input, client tools) | | `PermissionsRequestApprovalParams` | `PermissionsRequestApprovalResponse` |
|
||||
| `item/tool/call` | A | missing | S6 interactions (CW-7704: bidirectional approvals, input, client tools) | | `DynamicToolCallParams` | `DynamicToolCallResponse` |
|
||||
| `account/chatgptAuthTokens/refresh` | C | never-sent | ChatGPT externally managed auth tokens; no Codewhale service | | ChatgptAuthTokensRefreshParams (untyped) | ChatgptAuthTokensRefreshResponse (untyped) |
|
||||
| `attestation/generate` | C | never-sent | OpenAI request attestation; no Codewhale service | | AttestationGenerateParams (untyped) | AttestationGenerateResponse (untyped) |
|
||||
| `applyPatchApproval` | C | never-sent | deprecated v1 approvals for legacy turns; superseded by item/*/requestApproval | | ApplyPatchApprovalParams (untyped) | ApplyPatchApprovalResponse (untyped) |
|
||||
| `execCommandApproval` | C | never-sent | deprecated v1 approvals for legacy turns; superseded by item/*/requestApproval | | ExecCommandApprovalParams (untyped) | ExecCommandApprovalResponse (untyped) |
|
||||
|
||||
## Server notifications
|
||||
|
||||
| Method | Tier | Status | Basis | Recommended | Params | Result |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `error` | A | missing | S3 projection: errors | | `ErrorNotification` | |
|
||||
| `thread/started` | A | missing | S3 projection: thread lifecycle | | `ThreadStartedNotification` | |
|
||||
| `thread/status/changed` | A | missing | S3 projection: thread lifecycle | | `ThreadStatusChangedNotification` | |
|
||||
| `thread/readState/changed` | undecided | missing | thread state beyond the Tier A set | A | ThreadReadStateChangedNotification (untyped) | |
|
||||
| `thread/archived` | A | missing | S3 projection: thread lifecycle | | `ThreadArchivedNotification` | |
|
||||
| `thread/deleted` | B | missing | pairs with a Tier B service | | `ThreadDeletedNotification` | |
|
||||
| `thread/unarchived` | A | missing | S3 projection: thread lifecycle | | `ThreadUnarchivedNotification` | |
|
||||
| `thread/closed` | A | missing | S3 projection: thread lifecycle | | `ThreadClosedNotification` | |
|
||||
| `thread/reverted` | B | missing | pairs with a Tier B service | | `ThreadRevertedNotification` | |
|
||||
| `skills/changed` | B | missing | pairs with a Tier B service | | `SkillsChangedNotification` | |
|
||||
| `thread/name/updated` | A | missing | S3 projection: thread lifecycle | | `ThreadNameUpdatedNotification` | |
|
||||
| `thread/attachment/updated` | undecided | missing | Codex desktop attachments, predictions, projects and environments | C | ThreadAttachmentUpdatedNotification (untyped) | |
|
||||
| `thread/goal/updated` | A | missing | S3 projection with S8 goals | | `ThreadGoalUpdatedNotification` | |
|
||||
| `thread/prediction/updated` | undecided | missing | Codex desktop attachments, predictions, projects and environments | C | ThreadPredictionUpdatedNotification (untyped) | |
|
||||
| `thread/goal/cleared` | A | missing | S3 projection with S8 goals | | `ThreadGoalClearedNotification` | |
|
||||
| `thread/queue/changed` | undecided | missing | thread state beyond the Tier A set | A | ThreadQueueChangedNotification (untyped) | |
|
||||
| `project/changed` | undecided | missing | Codex desktop attachments, predictions, projects and environments | C | ProjectChangedNotification (untyped) | |
|
||||
| `thread/project/updated` | undecided | missing | Codex desktop attachments, predictions, projects and environments | C | ThreadProjectUpdatedNotification (untyped) | |
|
||||
| `thread/environment/connected` | undecided | missing | Codex desktop attachments, predictions, projects and environments | C | EnvironmentConnectionNotification (untyped) | |
|
||||
| `thread/environment/disconnected` | undecided | missing | Codex desktop attachments, predictions, projects and environments | C | EnvironmentConnectionNotification (untyped) | |
|
||||
| `thread/settings/updated` | undecided | missing | thread state beyond the Tier A set | A | ThreadSettingsUpdatedNotification (untyped) | |
|
||||
| `thread/tokenUsage/updated` | A | missing | S3 projection: thread lifecycle | | `ThreadTokenUsageUpdatedNotification` | |
|
||||
| `turn/started` | A | missing | S3 projection: turn/* | | `TurnStartedNotification` | |
|
||||
| `hook/started` | undecided | missing | hook runs; hooks/list is Tier B | B | HookStartedNotification (untyped) | |
|
||||
| `turn/completed` | A | missing | S3 projection: turn/* | | `TurnCompletedNotification` | |
|
||||
| `hook/completed` | undecided | missing | hook runs; hooks/list is Tier B | B | HookCompletedNotification (untyped) | |
|
||||
| `turn/diff/updated` | A | missing | S3 projection: turn/* | | `TurnDiffUpdatedNotification` | |
|
||||
| `turn/plan/updated` | A | missing | S3 projection: turn/* | | `TurnPlanUpdatedNotification` | |
|
||||
| `item/started` | A | missing | S3 projection: item/* | | `ItemStartedNotification` | |
|
||||
| `item/autoApprovalReview/started` | undecided | missing | Codex Guardian; Codewhale has its own Auto-Review posture | C | ItemGuardianApprovalReviewStartedNotification (untyped) | |
|
||||
| `item/autoApprovalReview/completed` | undecided | missing | Codex Guardian; Codewhale has its own Auto-Review posture | C | ItemGuardianApprovalReviewCompletedNotification (untyped) | |
|
||||
| `autoApprovalReview/strictReviewRequired` | undecided | missing | Codex Guardian; Codewhale has its own Auto-Review posture | C | StrictReviewRequiredNotification (untyped) | |
|
||||
| `item/completed` | A | missing | S3 projection: item/* | | `ItemCompletedNotification` | |
|
||||
| `rawResponseItem/completed` | C | never-sent | internal-only upstream (Codex Cloud, exact upstream usage); TypeScript-only in the export | | RawResponseItemCompletedNotification (untyped) | |
|
||||
| `rawResponse/completed` | C | never-sent | internal-only upstream (Codex Cloud, exact upstream usage); TypeScript-only in the export | | RawResponseCompletedNotification (untyped) | |
|
||||
| `item/agentMessage/delta` | A | missing | S3 projection: item/* | | `AgentMessageDeltaNotification` | |
|
||||
| `item/plan/delta` | A | missing | S3 projection: item/* | | `PlanDeltaNotification` | |
|
||||
| `command/exec/outputDelta` | B | missing | pairs with a Tier B service | | `CommandExecOutputDeltaNotification` | |
|
||||
| `process/outputDelta` | undecided | missing | background processes | B | ProcessOutputDeltaNotification (untyped) | |
|
||||
| `process/exited` | undecided | missing | background processes | B | ProcessExitedNotification (untyped) | |
|
||||
| `item/commandExecution/outputDelta` | A | missing | S3 projection: item/* | | `CommandExecutionOutputDeltaNotification` | |
|
||||
| `item/commandExecution/terminalInteraction` | A | missing | S3 projection: item/* | | `TerminalInteractionNotification` | |
|
||||
| `item/fileChange/outputDelta` | A | missing | S3 projection: item/* | | `FileChangeOutputDeltaNotification` | |
|
||||
| `item/fileChange/patchUpdated` | A | missing | S3 projection: item/* | | `FileChangePatchUpdatedNotification` | |
|
||||
| `serverRequest/resolved` | A | missing | S6 interactions (CW-7704) | | `ServerRequestResolvedNotification` | |
|
||||
| `item/mcpToolCall/progress` | A | missing | S3 projection: item/* | | `McpToolCallProgressNotification` | |
|
||||
| `mcpServer/oauthLogin/completed` | undecided | missing | MCP server events | B | McpServerOauthLoginCompletedNotification (untyped) | |
|
||||
| `mcpServer/startupStatus/updated` | undecided | missing | MCP server events | B | McpServerStatusUpdatedNotification (untyped) | |
|
||||
| `mcpServer/event/stream/notification` | undecided | missing | MCP server events | B | McpServerEventStreamNotification (untyped) | |
|
||||
| `account/updated` | undecided | missing | OpenAI account state | C | AccountUpdatedNotification (untyped) | |
|
||||
| `account/gatewayOAuth/changed` | C | never-sent | pairs with a Tier C method | | GatewayOAuthChangedNotification (untyped) | |
|
||||
| `account/rateLimits/updated` | undecided | missing | OpenAI account state | C | AccountRateLimitsUpdatedNotification (untyped) | |
|
||||
| `app/list/updated` | C | never-sent | pairs with a Tier C method | | AppListUpdatedNotification (untyped) | |
|
||||
| `remoteControl/status/changed` | undecided | missing | remote control status | C | RemoteControlStatusChangedNotification (untyped) | |
|
||||
| `externalAgentConfig/import/progress` | undecided | missing | pairs with externalAgentConfig/import | C | ExternalAgentConfigImportProgressNotification (untyped) | |
|
||||
| `externalAgentConfig/import/completed` | undecided | missing | pairs with externalAgentConfig/import | C | ExternalAgentConfigImportCompletedNotification (untyped) | |
|
||||
| `fs/changed` | B | missing | pairs with a Tier B service | | `FsChangedNotification` | |
|
||||
| `item/reasoning/summaryTextDelta` | A | missing | S3 projection: item/* | | `ReasoningSummaryTextDeltaNotification` | |
|
||||
| `item/reasoning/summaryPartAdded` | A | missing | S3 projection: item/* | | `ReasoningSummaryPartAddedNotification` | |
|
||||
| `item/reasoning/textDelta` | A | missing | S3 projection: item/* | | `ReasoningTextDeltaNotification` | |
|
||||
| `thread/compacted` | B | missing | pairs with a Tier B service | | `ContextCompactedNotification` | |
|
||||
| `model/rerouted` | undecided | missing | OpenAI model routing, verification, moderation and safety | C | ModelReroutedNotification (untyped) | |
|
||||
| `model/verification` | undecided | missing | OpenAI model routing, verification, moderation and safety | C | ModelVerificationNotification (untyped) | |
|
||||
| `modelProvider/authRecoveryStarted` | undecided | missing | provider auth recovery | B | AuthRecoveryNotification (untyped) | |
|
||||
| `modelProvider/authRecoveryCompleted` | undecided | missing | provider auth recovery | B | AuthRecoveryNotification (untyped) | |
|
||||
| `turn/moderationMetadata` | undecided | missing | OpenAI model routing, verification, moderation and safety | C | TurnModerationMetadataNotification (untyped) | |
|
||||
| `model/safetyBuffering/updated` | undecided | missing | OpenAI model routing, verification, moderation and safety | C | ModelSafetyBufferingUpdatedNotification (untyped) | |
|
||||
| `warning` | undecided | missing | generic warnings | A | WarningNotification (untyped) | |
|
||||
| `guardianWarning` | undecided | missing | Codex Guardian; Codewhale has its own Auto-Review posture | C | GuardianWarningNotification (untyped) | |
|
||||
| `deprecationNotice` | undecided | missing | generic warnings | A | DeprecationNoticeNotification (untyped) | |
|
||||
| `configWarning` | undecided | missing | generic warnings | A | ConfigWarningNotification (untyped) | |
|
||||
| `fuzzyFileSearch/sessionUpdated` | undecided | missing | pairs with fuzzyFileSearch | B | FuzzyFileSearchSessionUpdatedNotification (untyped) | |
|
||||
| `fuzzyFileSearch/sessionCompleted` | undecided | missing | pairs with fuzzyFileSearch | B | FuzzyFileSearchSessionCompletedNotification (untyped) | |
|
||||
| `thread/realtime/started` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeStartedNotification (untyped) | |
|
||||
| `thread/realtime/itemAdded` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeItemAddedNotification (untyped) | |
|
||||
| `thread/realtime/item/started` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeItemStartedNotification (untyped) | |
|
||||
| `thread/realtime/item/transcript/delta` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeItemTranscriptDeltaNotification (untyped) | |
|
||||
| `thread/realtime/item/completed` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeItemCompletedNotification (untyped) | |
|
||||
| `thread/realtime/transcript/delta` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeTranscriptDeltaNotification (untyped) | |
|
||||
| `thread/realtime/transcript/done` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeTranscriptDoneNotification (untyped) | |
|
||||
| `thread/realtime/outputAudio/delta` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeOutputAudioDeltaNotification (untyped) | |
|
||||
| `thread/realtime/sdp` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeSdpNotification (untyped) | |
|
||||
| `thread/realtime/error` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeErrorNotification (untyped) | |
|
||||
| `thread/realtime/closed` | undecided | missing | realtime audio sessions; no Codewhale equivalent | C | ThreadRealtimeClosedNotification (untyped) | |
|
||||
| `windows/worldWritableWarning` | undecided | missing | Codex Windows sandbox | C | WindowsWorldWritableWarningNotification (untyped) | |
|
||||
| `windowsSandbox/setupCompleted` | undecided | missing | Codex Windows sandbox | C | WindowsSandboxSetupCompletedNotification (untyped) | |
|
||||
| `account/login/completed` | undecided | missing | OpenAI account state | C | AccountLoginCompletedNotification (untyped) | |
|
||||
|
||||
## Totals
|
||||
|
||||
| Kind | A | B | C | undecided |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Client requests | 19 | 25 | 19 | 46 |
|
||||
| Client notifications | 1 | 0 | 0 | 0 |
|
||||
| Server requests | 5 | 0 | 4 | 1 |
|
||||
| Server notifications | 27 | 6 | 4 | 50 |
|
||||
|
||||
Typed definitions generated into `src/v2.rs`: 319.
|
||||
|
||||
## Errors
|
||||
|
||||
Codex envelopes carry no `jsonrpc` member. ACP keeps its own JSON-RPC 2.0 envelope and is not this contract.
|
||||
|
||||
| Name | Code | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `PARSE_ERROR` | -32700 | the line or message is not JSON |
|
||||
| `INVALID_REQUEST` | -32600 | not a valid request envelope, or a request before initialize |
|
||||
| `METHOD_NOT_FOUND` | -32601 | Tier C or undecided method, or a name the pinned export does not have |
|
||||
| `INVALID_PARAMS` | -32602 | params do not decode into the selected method's typed params |
|
||||
| `INTERNAL_ERROR` | -32603 | the Engine failed while serving a valid request |
|
||||
| `OVERLOADED` | -32001 | upstream Codex transport overload code; the outgoing queue is full |
|
||||
| `THREAD_NOT_FOUND` | -32004 | the named thread does not exist (existing App Server code) |
|
||||
| `RUNTIME_UNAVAILABLE` | -32005 | the Engine could not be reached (existing App Server code) |
|
||||
| `CAPABILITY_UNSUPPORTED` | -32020 | the method exists and is selected, but this thread's executor or placement cannot serve it; `data` is `CapabilityRefusalData`; the request has no side effect and never falls back to local execution |
|
||||
|
||||
## Codewhale extensions
|
||||
|
||||
These ride on Codex shapes without changing any pinned field. The pinned `Thread` schema allows additional properties, so a Codex client ignores the fields. Codewhale notifications live under `codewhale/`, because Codex already uses `thread/environment/*` for its own environments. Extension shapes are declared under `extensions.definitions` in the manifest and generated with the Codex types; none carries a credential.
|
||||
|
||||
| Name | Kind | Tier | Status | Shape |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Thread.executor` | field | A | defined, unpopulated | `ThreadExecutor` `{ kind, agentId, agentName? }`: a thread driven by an external agent over ACP; absent for a native thread. Never an environment value or a credential. |
|
||||
| `Thread.environment` | field | B | defined, unpopulated | `ThreadPlacement` `{ id, providerKind }` (named apart from Codex `ThreadEnvironment`, which is a different type): where the thread runs; absent means this computer. A thread placed in an environment this build cannot serve refuses with `CAPABILITY_UNSUPPORTED` (`environment_not_supported`) and never runs locally. |
|
||||
| `codewhale/environment/*` | server notifications | B | reserved, not implemented | environment state changes for placement |
|
||||
| `ThreadItem.placement` | field | B | reserved, not implemented | where a child or job ran |
|
||||
| `approval.decided.responder` | Runtime journal field | A | implemented | the client that answered (`desktop`, `phone`, `web`, `tui`, `acp`), as declared by that client |
|
||||
@@ -1,6 +0,0 @@
|
||||
OpenAI Codex
|
||||
Copyright 2025 OpenAI
|
||||
|
||||
This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license.
|
||||
Copyright (c) 2016-2022 Florian Dehau
|
||||
Copyright (c) 2023-2025 The Ratatui Developers
|
||||
@@ -1,57 +0,0 @@
|
||||
# Codewhale App Server protocol
|
||||
|
||||
This crate owns the existing Runtime image, event-envelope, capability and
|
||||
dynamic-tool contracts, plus the shared RPC envelope and initialization shapes.
|
||||
Engine, App Server, hooks and the SDK consume that one
|
||||
family; the former `codewhale_protocol::runtime` module is removed.
|
||||
|
||||
The runtime contracts reuse serde and the existing workspace Chrono dependency. The optional `schema` feature uses
|
||||
the existing locked schemars dependency and shared JSON Schema to TypeScript
|
||||
renderer. It selects input/output schema contracts from the actual owner. Runtime output
|
||||
fields reflect serialization while accepted input defaults stay optional.
|
||||
DynamicToolSpecOutput is the serialized view of the same DynamicToolSpec Rust
|
||||
type used in thread-created output. Runtime TypeScript uses JSON-serializable
|
||||
object members (which may omit undefined values); root/array undefined remains
|
||||
excluded. The existing Native host generator keeps its prior text and
|
||||
present-value normalization.
|
||||
|
||||
Generate outputs with:
|
||||
|
||||
```sh
|
||||
cargo run -p codewhale-app-server-protocol --features schema --bin codewhale-app-server-schema -- --write
|
||||
```
|
||||
|
||||
The command writes `schema/json/runtime.schema.json`,
|
||||
`schema/typescript/runtime.d.ts`, and the matching packaged SDK declaration.
|
||||
`--check` compares all three without writing. Normal runtime startup does not
|
||||
generate files or enable schema machinery.
|
||||
|
||||
`contract/codex-v2.json` is the frozen, versioned Codex v2 contract manifest:
|
||||
every pinned stable Codex method with its tier (A, B, C or undecided), status,
|
||||
basis and, for selected methods, the upstream variant, params and result types;
|
||||
the dispatch rule for each tier; the error codes; and the Codewhale extensions.
|
||||
`src/codegen` reads it with the unmodified pinned export and writes
|
||||
`src/methods.rs`, `src/v2.rs`, `METHODS.md`,
|
||||
`schema/json/codex-v2.schema.json` and `schema/typescript/codex-v2.d.ts`:
|
||||
|
||||
```sh
|
||||
cargo run -p codewhale-app-server-protocol --bin codewhale-codex-contract -- --write
|
||||
```
|
||||
|
||||
`--check` compares without writing, and `tests/contract.rs` fails on any drift,
|
||||
on any disagreement between the manifest and the pinned method sets, and on
|
||||
any selected shape whose typed Rust value does not round-trip an instance the
|
||||
pinned schema accepts. `v2::ClientRequestParams`, `v2::ServerNotification`,
|
||||
`v2::ServerRequestParams`, `v2::ClientResponse` and `v2::ServerRequestResponse`
|
||||
are the typed envelopes; Codex envelopes carry no `jsonrpc` member. A typed
|
||||
shape is not a handler: `METHODS.md` status says what main dispatches.
|
||||
|
||||
The selected future Codex wire target is official `rust-v0.166.0`, commit
|
||||
`6ff1e1ca3b30fc8b914720681fcf5fe62e95f856`. Its four-variant envelope and initialize/initialized lifecycle serve JSONL
|
||||
stdio and WebSocket text messages over the private owner channel. The private
|
||||
framing retains kernel peer and attach checks, bounded headers and messages, and
|
||||
ACP's separate JSON-RPC 2.0 envelope. TCP WebSocket listener, exact thread/turn/item
|
||||
dispatch, subscriptions, interactions and their complete generated SDK remain separate required adoption
|
||||
slices; legacy compatibility methods do not establish full Codex wire parity.
|
||||
The existing Runtime journal and native Engine receipt/store authority remain
|
||||
where they are; no serialized observation creates publication authority.
|
||||
@@ -1,11 +0,0 @@
|
||||
# Upstream contract provenance
|
||||
|
||||
The request-ID, request/notification/error data shapes in src/rpc.rs and initialization data shapes in src/lifecycle.rs are adapted from official openai/codex rust-v0.166.0, immutable commit6ff1e1ca3b30fc8b914720681fcf5fe62e95f856, codex-rs/app-server-protocol/src/rpc.rs and src/protocol/v1.rs. Apache-2.0 LICENSE-APACHE and upstream NOTICE are retained with the original Codewhale MIT license in LICENSE-MIT. The crate combines MIT Codewhale implementation with Apache-2.0 contract adaptations.
|
||||
|
||||
Changed files: these adaptations remove Codex-specific derive/export dependencies and replace the absolute-path wrapper with PathBuf on the server-created response, whose producer validates absolute scope. RpcCall normalizes existing optional jsonrpc compatibility input and requires present request IDs to be strings or i64. It preserves notification ID absence. Correlated results and errors require RequestId. The existing compatibility uncorrelated parse-error helper uses a distinct null-ID error record; that response does not satisfy the pinned correlated Codex error envelope and remains explicit phase4 closure work. No private Engine source observation, receipt or accounting producer proof appears in this protocol crate.
|
||||
|
||||
The exact pinned wire golden fixtures under tests/pinned are unmodified members of the local stable precomputed schema export. This is a staged owner/lifecycle adoption, not complete wire/parity acceptance.
|
||||
|
||||
The method sets in tests/pinned/codex/{ClientRequest,ClientNotification,ServerRequest,ServerNotification}.ts are unmodified TypeScript members of the same commit's stable precomputed export (`codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst`, compressed SHA256 `c3802deddc224896967bccd29e353e93e62af768b44dbd5d583e35825fb3e02a`). Member SHA256: ClientRequest.ts `d52eab2b624ee8bab88a121f78a5465faaa6a2701bbd0d2979dc7e5937741b36`, ClientNotification.ts `96692c191ff9ac32c2688b46a0a04f23d15646937adc352f2adcc8211c6d17a0`, ServerRequest.ts `30880f1a8da876eec30600ba27560d05ad0db5c645e14564c9c79a2429274f06`, ServerNotification.ts `df9b5ab2f11010ac336fb7c5b6bb386a7c389cd5dbe742ac5c804fcc69bd40d9`. The TypeScript members are used because they carry the full wire enums: the export's JSON schemas omit three deprecated v1 client requests and two internal-only notifications. Variant names in src/methods.rs follow the upstream `common.rs` variants.
|
||||
|
||||
The selected v2 contract is generated from two further unmodified pins of the same commit. `tests/pinned/codex/codex_app_server_protocol.schemas.json` is the `json_schema/codex_app_server_protocol.schemas.json` member of the stable precomputed export (expanded export SHA256 `7ae41cc257d2a4d9f93d1e99f24560802439d2f2d050ec5685832025ced962a2`; member SHA256 `9b6d53647b00d68a04181b826599025a810cfa78acb3ec4d518c68b4120499c0`). `tests/pinned/codex/common.rs` is `codex-rs/app-server-protocol/src/protocol/common.rs` (Git blob `2936c01855e463275d8306df8583ec0303e5c854`, SHA256 `b7f0a90dcc822962e59890b252a43142a113cb1355f333d8c6b0f704b97f89f7`); it is read only by `tests/contract.rs` to check each selected request's result type and is not compiled. The Rust types in `src/v2.rs`, the TypeScript in `schema/typescript/codex-v2.d.ts` and the selected-closure JSON Schema in `schema/json/codex-v2.schema.json` are derived from the schema member by `src/codegen`; they are Apache-2.0 contract adaptations like the files above.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,632 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$defs": {
|
||||
"RuntimeImageInput": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mime": {
|
||||
"type": "string"
|
||||
},
|
||||
"dataBase64": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mime",
|
||||
"dataBase64"
|
||||
],
|
||||
"description": "Inline bytes only: neither host paths nor remote URLs confer attachment authority."
|
||||
},
|
||||
"ThreadLoadedListParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ThreadSetNameParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threadId",
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"DynamicToolSpec": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional namespace that groups related tools (e.g. `\"tau_bench\"`).\nWhen present, the runtime may expose the tool as\n`<namespace>::<name>` to the model."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Short tool name. Combined with `namespace` it forms a unique tool id."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable description exposed to the model."
|
||||
},
|
||||
"input_schema": {
|
||||
"description": "JSON Schema describing the tool's input parameters."
|
||||
},
|
||||
"defer_loading": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the runtime may defer schema validation / tool loading until\nthe model actually calls the tool.\n\nDefaults to `false` so that older clients omitting this field still\nbehave the same way.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"input_schema"
|
||||
],
|
||||
"description": "Specification for a dynamic external tool registered by a runtime client.\n\nExample JSON from the spec:\n\n```json\n{\n \"namespace\": \"tau_bench\",\n \"name\": \"get_reservation\",\n \"description\": \"Look up an airline reservation.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"reservation_id\": { \"type\": \"string\" }\n },\n \"required\": [\"reservation_id\"],\n \"additionalProperties\": false\n }\n}\n```"
|
||||
},
|
||||
"DynamicToolItemStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"in_progress",
|
||||
"completed",
|
||||
"failed"
|
||||
],
|
||||
"description": "Lifecycle status of a dynamic tool item shown in thread detail and event\npayloads."
|
||||
},
|
||||
"DynamicToolCallResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the client-side tool execution succeeded."
|
||||
},
|
||||
"content": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/DynamicToolCallContent"
|
||||
},
|
||||
"description": "Content fragments returned by the tool.\n\nDefaults to an empty vector when omitted so clients can send a minimal\n`{ \"success\": false }` payload.",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"success"
|
||||
],
|
||||
"description": "Result submitted by a runtime client after executing a dynamic tool."
|
||||
},
|
||||
"DynamicToolCallContent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "input_text"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "input_image"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"image_url"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A single content fragment inside a [`DynamicToolCallResult`]."
|
||||
},
|
||||
"TurnEnvironmentParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"environment_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"environment_id",
|
||||
"cwd"
|
||||
],
|
||||
"description": "Environment target selected for a turn's shell/filesystem work.\n\nExample JSON:\n\n```json\n{\n \"environment_id\": \"local\",\n \"cwd\": \"/workspace\"\n}\n```"
|
||||
},
|
||||
"RuntimeEventEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"default": 1
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0
|
||||
},
|
||||
"event": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"thread_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"item_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"payload": true
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"seq",
|
||||
"event",
|
||||
"kind",
|
||||
"thread_id",
|
||||
"turn_id",
|
||||
"item_id",
|
||||
"timestamp",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": true
|
||||
},
|
||||
"ThreadLoadedListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"nextCursor": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"nextCursor"
|
||||
]
|
||||
},
|
||||
"ThreadSetNameResponse": {
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RuntimeCapabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_token_intents": {
|
||||
"type": "boolean",
|
||||
"description": "Device client tokens have immutable watch/drive intent. Watch cannot\nmutate Runtime state, acquire control, or forward display input.",
|
||||
"default": false
|
||||
},
|
||||
"account_session": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"account_model_owner": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"session_provider_keys": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"threads": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"thread_create_idempotency": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_idle_precondition": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_patch_undo_turn_id": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_scoped_reads": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_scoped_git_writes": {
|
||||
"type": "boolean",
|
||||
"description": "Git stage, unstage, discard, commit, push and branch accept\n`?thread_id=` and act on that thread's workspace.",
|
||||
"default": false
|
||||
},
|
||||
"thread_scoped_snapshots": {
|
||||
"type": "boolean",
|
||||
"description": "`GET /v1/snapshots` and `POST /v1/snapshots/{id}/restore` accept\n`?thread_id=` and act on that thread's workspace.",
|
||||
"default": false
|
||||
},
|
||||
"thread_scoped_terminals": {
|
||||
"type": "boolean",
|
||||
"description": "`GET /v1/threads/{id}/terminals` lists the thread's live shells, and\n`/v1/threads/{id}/terminals/{terminal_id}/output|input|resize|kill`\ndrive one by the identity the Engine issued it.",
|
||||
"default": false
|
||||
},
|
||||
"worktrees": {
|
||||
"type": "boolean",
|
||||
"description": "`GET`/`POST /v1/worktrees` and `POST /v1/worktrees/remove`.",
|
||||
"default": false
|
||||
},
|
||||
"job_operations": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_shell_consent": {
|
||||
"type": "boolean",
|
||||
"description": "Explicit per-thread shell opt-in is checked against loaded policy and\ncannot broaden a conversation while it has an active turn.",
|
||||
"default": false
|
||||
},
|
||||
"turns": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"turn_operation_idempotency": {
|
||||
"type": "boolean",
|
||||
"description": "`POST /v1/threads/{id}/turns` accepts a durable, thread-scoped\n`operation_key` and returns the original turn for exact retries.",
|
||||
"default": false
|
||||
},
|
||||
"turn_operation_lookup": {
|
||||
"type": "boolean",
|
||||
"description": "Read-only exact accepted-turn lookup by thread and operation key.",
|
||||
"default": false
|
||||
},
|
||||
"watch_event_turns": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"structured_review_turns": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"structured_review_version": {
|
||||
"type": "integer",
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
},
|
||||
"automation_mutation_preconditions": {
|
||||
"type": "boolean",
|
||||
"description": "Automation patch/run/pause/resume honor `expected_revision` (409\n`revision_conflict` carrying `current_revision`); create/patch/run/pause/\nresume honor a bounded, persisted `Idempotency-Key`. Delete honors\nneither.",
|
||||
"default": false
|
||||
},
|
||||
"turn_image_inputs": {
|
||||
"type": "boolean",
|
||||
"description": "Bounded inline image inputs, persisted and replayed with their turn.",
|
||||
"default": false
|
||||
},
|
||||
"turn_output_token_limit": {
|
||||
"type": "boolean",
|
||||
"description": "Per-turn maxOutputTokens is validated and intersected with the route ceiling.",
|
||||
"default": false
|
||||
},
|
||||
"profile_constitution": {
|
||||
"type": "boolean",
|
||||
"description": "Account profile snapshots and Engine-owned constitution preview.",
|
||||
"default": false
|
||||
},
|
||||
"turn_steer": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"turn_interrupt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"event_replay": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"external_tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"environments": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"worker_runtime": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fleet_run_create": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_fleet_members": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"fleet_run_start": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"fleet_event_replay": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"fleet_event_stream": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"fleet_local_target": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"thread_goals": {
|
||||
"type": "boolean",
|
||||
"description": "`GET/PUT/DELETE /v1/threads/{id}/goal` and the `complete`/`block`\nlifecycle actions are available.",
|
||||
"default": false
|
||||
},
|
||||
"memory": {
|
||||
"type": "boolean",
|
||||
"description": "`GET /v1/memory` and `GET /v1/memory/{id}` are available for\nbounded inspection of the native memory store. `POST /v1/memory`\nand `DELETE /v1/memory` are also available (auth-gated via the\nstandard route layer) for lifecycle controls.",
|
||||
"default": false
|
||||
},
|
||||
"mcp_server_management": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the runtime supports create/update/enable/disable/reconnect/delete\noperations on MCP server configuration via the `POST|GET|PATCH|DELETE\n/v1/apps/mcp/servers` family of endpoints.",
|
||||
"default": false
|
||||
},
|
||||
"skill_lifecycle": {
|
||||
"type": "boolean",
|
||||
"description": "Skill lifecycle operations (install, update, uninstall, trust, audit)\nare available via the HTTP API.",
|
||||
"default": false
|
||||
},
|
||||
"skill_detail": {
|
||||
"type": "boolean",
|
||||
"description": "`GET /v1/skills/{name}` returns one skill's full body and routing\nmetadata, so a client can compose an activation instruction for its\nnext turn. `GET /v1/skills` rows also carry `invocation`, `aliases`,\nand `bundled_tier` routing fields when this flag is set.",
|
||||
"default": false
|
||||
},
|
||||
"plugin_management": {
|
||||
"type": "boolean",
|
||||
"description": "Plugin bundle and marketplace lifecycle operations (list/detail,\ninstall/update/uninstall, trust/enable/disable/revoke, marketplace\nadd/remove/install) are available via the `/v1/apps/plugins` and\n`/v1/apps/marketplaces` endpoint families.",
|
||||
"default": false
|
||||
},
|
||||
"agent_mail": {
|
||||
"type": "boolean",
|
||||
"description": "Durable, workspace-scoped cross-task Agent Mail endpoints and events.",
|
||||
"default": false
|
||||
},
|
||||
"event_stream_resume": {
|
||||
"type": "boolean",
|
||||
"description": "`GET /v1/threads/{id}/events` puts the durable `seq` on every journal\nframe as the SSE `id:` and resumes from a `Last-Event-ID` header, so a\nbrowser `EventSource` reconnects without a cursor in the query string.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"client_token_intents",
|
||||
"account_session",
|
||||
"account_model_owner",
|
||||
"session_provider_keys",
|
||||
"threads",
|
||||
"thread_create_idempotency",
|
||||
"thread_idle_precondition",
|
||||
"thread_patch_undo_turn_id",
|
||||
"thread_scoped_reads",
|
||||
"thread_scoped_git_writes",
|
||||
"thread_scoped_snapshots",
|
||||
"thread_scoped_terminals",
|
||||
"worktrees",
|
||||
"job_operations",
|
||||
"thread_shell_consent",
|
||||
"turns",
|
||||
"turn_operation_idempotency",
|
||||
"turn_operation_lookup",
|
||||
"watch_event_turns",
|
||||
"structured_review_turns",
|
||||
"structured_review_version",
|
||||
"automation_mutation_preconditions",
|
||||
"turn_image_inputs",
|
||||
"turn_output_token_limit",
|
||||
"profile_constitution",
|
||||
"turn_steer",
|
||||
"turn_interrupt",
|
||||
"event_replay",
|
||||
"external_tools",
|
||||
"environments",
|
||||
"worker_runtime",
|
||||
"fleet_run_create",
|
||||
"thread_fleet_members",
|
||||
"fleet_run_start",
|
||||
"fleet_event_replay",
|
||||
"fleet_event_stream",
|
||||
"fleet_local_target",
|
||||
"thread_goals",
|
||||
"memory",
|
||||
"mcp_server_management",
|
||||
"skill_lifecycle",
|
||||
"skill_detail",
|
||||
"plugin_management",
|
||||
"agent_mail",
|
||||
"event_stream_resume"
|
||||
],
|
||||
"description": "Fixed capability map advertised by `GET /v1/runtime/info`.\n\nAll fields are required on serialization so clients can rely on the shape."
|
||||
},
|
||||
"FleetProfileDocument": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/$defs/FleetProfileDocumentKind"
|
||||
},
|
||||
"origin": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"revision": {
|
||||
"type": "string"
|
||||
},
|
||||
"writable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"refusal_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"origin",
|
||||
"path",
|
||||
"text",
|
||||
"revision",
|
||||
"writable",
|
||||
"refusal_reason"
|
||||
]
|
||||
},
|
||||
"FleetProfileDocumentKind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"profile",
|
||||
"fleet"
|
||||
]
|
||||
},
|
||||
"RuntimeExperimentalCapabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"environments": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"environments"
|
||||
],
|
||||
"description": "Experimental opt-in flags advertised by `GET /v1/runtime/info`.\n\nFields are additive and default to `false` when omitted by older servers."
|
||||
},
|
||||
"DynamicToolCallParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"thread_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "Optional namespace that was registered with the tool."
|
||||
},
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"description": "Tool name that the model invoked."
|
||||
},
|
||||
"arguments": {
|
||||
"description": "Arguments supplied by the model, validated against `input_schema`."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"thread_id",
|
||||
"turn_id",
|
||||
"call_id",
|
||||
"tool",
|
||||
"arguments"
|
||||
],
|
||||
"description": "Parameters identifying a dynamic tool call request emitted by the runtime.\n\nThis is the typed payload for `tool_call.requested` events and also the\nnatural identifier used when the runtime looks up a pending call."
|
||||
},
|
||||
"DynamicToolSpecOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "Optional namespace that groups related tools (e.g. `\"tau_bench\"`).\nWhen present, the runtime may expose the tool as\n`<namespace>::<name>` to the model."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Short tool name. Combined with `namespace` it forms a unique tool id."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable description exposed to the model."
|
||||
},
|
||||
"input_schema": {
|
||||
"description": "JSON Schema describing the tool's input parameters."
|
||||
},
|
||||
"defer_loading": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the runtime may defer schema validation / tool loading until\nthe model actually calls the tool.\n\nDefaults to `false` so that older clients omitting this field still\nbehave the same way.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"input_schema",
|
||||
"defer_loading"
|
||||
],
|
||||
"description": "Specification for a dynamic external tool registered by a runtime client.\n\nExample JSON from the spec:\n\n```json\n{\n \"namespace\": \"tau_bench\",\n \"name\": \"get_reservation\",\n \"description\": \"Look up an airline reservation.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"reservation_id\": { \"type\": \"string\" }\n },\n \"required\": [\"reservation_id\"],\n \"additionalProperties\": false\n }\n}\n```"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
export type AbsolutePathBuf = string;
|
||||
export type AdditionalFileSystemPermissions = { "entries"?: Array<FileSystemSandboxEntry> | null; "globScanMaxDepth"?: number | null; "read"?: Array<LegacyAppPathString> | null; "write"?: Array<LegacyAppPathString> | null };
|
||||
export type AdditionalNetworkPermissions = { "enabled"?: boolean | null };
|
||||
export type AgentMessageDelivery = "async";
|
||||
export type AgentMessageDeltaNotification = { "delta": string; "itemId": string; "threadId": string; "turnId": string };
|
||||
export type AgentPath = string;
|
||||
export type AllowDenyRequirement = "allow" | "deny";
|
||||
export type AnalyticsConfig = { "enabled"?: boolean | null; [key: string]: unknown };
|
||||
export type ApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
|
||||
export type AskForApproval = "untrusted" | "on-request" | "never" | { "granular": { "mcp_elicitations": boolean; "request_permissions"?: boolean; "rules": boolean; "sandbox_approval": boolean; "skill_approval"?: boolean } };
|
||||
export type AsyncUserInputQuestion = { "options"?: Array<string> | null; "title": string };
|
||||
export type AutoCompactTokenLimitScope = "total" | "body_after_prefix";
|
||||
export type BrowserUseConfig = { "allow_history_access"?: boolean | null; "default_origin_policy"?: BrowserUseOriginPolicyConfig | null; "origins"?: { [key: string]: BrowserUseOriginPolicyConfig } | null };
|
||||
export type BrowserUseOriginPolicyConfig = { "access"?: AllowDenyRequirement | null; "downloads"?: AllowDenyRequirement | null; "full_cdp_access"?: AllowDenyRequirement | null; "uploads"?: AllowDenyRequirement | null };
|
||||
export type ByteRange = { "end": number; "start": number };
|
||||
export type CapabilityRefusal = { "code": CapabilityRefusalCode; "method"?: string | null; "threadId"?: string | null };
|
||||
export type CapabilityRefusalCode = "unknown_agent" | "not_installed" | "adapter_missing" | "below_min_version" | "needs_sign_in" | "no_acp" | "executor_not_supported" | "environment_not_supported" | "steer_not_supported" | "reopen_not_supported";
|
||||
export type CapabilityRefusalData = { "codewhaleRefusal": CapabilityRefusal };
|
||||
export type ClientInfo = { "name": string; "title"?: string | null; "version": string };
|
||||
export type CodexErrorInfo = CodexErrorInfoVariant0 | CodexErrorInfoVariant1 | CodexErrorInfoVariant2 | CodexErrorInfoVariant3 | CodexErrorInfoVariant4 | CodexErrorInfoVariant5 | unknown;
|
||||
export type CodexErrorInfoVariant0 = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "flexUnavailable" | "serverOverloaded" | "modelNotFound" | "policyCapacityUnavailable" | "cyberPolicy" | "misalignmentPolicyViolation" | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | "other";
|
||||
export type CodexErrorInfoVariant1 = { "httpConnectionFailed": CodexErrorInfoVariant1HttpConnectionFailed };
|
||||
export type CodexErrorInfoVariant1HttpConnectionFailed = { "httpStatusCode"?: number | null };
|
||||
export type CodexErrorInfoVariant2 = { "responseStreamConnectionFailed": CodexErrorInfoVariant2ResponseStreamConnectionFailed };
|
||||
export type CodexErrorInfoVariant2ResponseStreamConnectionFailed = { "httpStatusCode"?: number | null };
|
||||
export type CodexErrorInfoVariant3 = { "responseStreamDisconnected": CodexErrorInfoVariant3ResponseStreamDisconnected };
|
||||
export type CodexErrorInfoVariant3ResponseStreamDisconnected = { "httpStatusCode"?: number | null };
|
||||
export type CodexErrorInfoVariant4 = { "responseTooManyFailedAttempts": CodexErrorInfoVariant4ResponseTooManyFailedAttempts };
|
||||
export type CodexErrorInfoVariant4ResponseTooManyFailedAttempts = { "httpStatusCode"?: number | null };
|
||||
export type CodexErrorInfoVariant5 = { "activeTurnNotSteerable": CodexErrorInfoVariant5ActiveTurnNotSteerable };
|
||||
export type CodexErrorInfoVariant5ActiveTurnNotSteerable = { "turnKind": NonSteerableTurnKind };
|
||||
export type CollabAgentState = { "message"?: string | null; "status": CollabAgentStatus };
|
||||
export type CollabAgentStatus = "pendingInit" | "running" | "interrupted" | "completed" | "errored" | "shutdown" | "notFound";
|
||||
export type CollabAgentTool = "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent" | "sendMessage" | "followupTask" | "interruptAgent" | "listAgents";
|
||||
export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed" | "interrupted";
|
||||
export type CollaborationMode = { "mode": ModeKind; "settings": Settings };
|
||||
export type CommandAction = { "command": string; "name": string; "path": LegacyAppPathString; "type": "read" } | { "command": string; "path"?: string | null; "type": "listFiles" } | { "command": string; "path"?: string | null; "query"?: string | null; "type": "search" } | { "command": string; "type": "unknown" };
|
||||
export type CommandExecOutputDeltaNotification = { "capReached": boolean; "deltaBase64": string; "processId": string; "stream": CommandExecOutputStream };
|
||||
export type CommandExecOutputStream = "stdout" | "stderr";
|
||||
export type CommandExecParams = { "command": Array<string>; "cwd"?: string | null; "disableOutputCap"?: boolean; "disableTimeout"?: boolean; "env"?: { [key: string]: string | null } | null; "outputBytesCap"?: number | null; "processId"?: string | null; "sandboxPolicy"?: SandboxPolicy | null; "size"?: CommandExecTerminalSize | null; "streamStdin"?: boolean; "streamStdoutStderr"?: boolean; "timeoutMs"?: number | null; "tty"?: boolean };
|
||||
export type CommandExecResizeParams = { "processId": string; "size": CommandExecTerminalSize };
|
||||
export type CommandExecResizeResponse = Record<string, never>;
|
||||
export type CommandExecResponse = { "exitCode": number; "stderr": string; "stdout": string };
|
||||
export type CommandExecTerminalSize = { "cols": number; "rows": number };
|
||||
export type CommandExecTerminateParams = { "processId": string };
|
||||
export type CommandExecTerminateResponse = Record<string, never>;
|
||||
export type CommandExecWriteParams = { "closeStdin"?: boolean; "deltaBase64"?: string | null; "processId": string };
|
||||
export type CommandExecWriteResponse = Record<string, never>;
|
||||
export type CommandExecutionApprovalDecision = "accept" | "acceptForSession" | { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": Array<string> } } | { "applyNetworkPolicyAmendment": { "network_policy_amendment": NetworkPolicyAmendment } } | "decline" | "cancel";
|
||||
export type CommandExecutionApprovalKind = "command" | "writeStdin";
|
||||
export type CommandExecutionOutputDeltaNotification = { "delta": string; "itemId": string; "threadId": string; "turnId": string };
|
||||
export type CommandExecutionRequestApprovalParams = { "approvalId"?: string | null; "command"?: string | null; "commandActions"?: Array<CommandAction> | null; "cwd"?: LegacyAppPathString | null; "environmentId"?: string | null; "itemId": string; "kind"?: CommandExecutionApprovalKind; "networkApprovalContext"?: NetworkApprovalContext | null; "proposedExecpolicyAmendment"?: Array<string> | null; "proposedNetworkPolicyAmendments"?: Array<NetworkPolicyAmendment> | null; "reason"?: string | null; "startedAtMs": number; "threadId": string; "turnId": string };
|
||||
export type CommandExecutionRequestApprovalResponse = { "decision": CommandExecutionApprovalDecision };
|
||||
export type CommandExecutionSource = "agent" | "userShell" | "unifiedExecStartup" | "unifiedExecInteraction";
|
||||
export type CommandExecutionStatus = "inProgress" | "completed" | "failed" | "declined";
|
||||
export type ComputerUseConfig = { "default_app_access"?: AllowDenyRequirement | null; "macos"?: ComputerUseMacosConfig | null; "windows"?: ComputerUseWindowsConfig | null };
|
||||
export type ComputerUseMacosConfig = { "bundle_ids"?: { [key: string]: AllowDenyRequirement } | null };
|
||||
export type ComputerUseWindowsConfig = { "aumids"?: { [key: string]: AllowDenyRequirement } | null; "exes"?: Array<ComputerUseWindowsExeConfig> | null };
|
||||
export type ComputerUseWindowsExeConfig = { "access": AllowDenyRequirement; "binary_name"?: string | null; "product_name": string; "publisher_name": string };
|
||||
export type Config = { "analytics"?: AnalyticsConfig | null; "approval_policy"?: AskForApproval | null; "approvals_reviewer"?: ApprovalsReviewer | null; "browser_use"?: BrowserUseConfig | null; "compact_prompt"?: string | null; "computer_use"?: ComputerUseConfig | null; "desktop"?: { [key: string]: unknown } | null; "developer_instructions"?: string | null; "forced_chatgpt_workspace_id"?: ForcedChatgptWorkspaceIds | null; "forced_login_method"?: ForcedLoginMethod | null; "instructions"?: string | null; "model"?: string | null; "model_auto_compact_token_limit"?: number | null; "model_auto_compact_token_limit_scope"?: AutoCompactTokenLimitScope | null; "model_context_window"?: number | null; "model_provider"?: string | null; "model_reasoning_effort"?: ReasoningEffort | null; "model_reasoning_summary"?: ReasoningSummary | null; "model_verbosity"?: Verbosity | null; "review_model"?: string | null; "sandbox_mode"?: SandboxMode | null; "sandbox_workspace_write"?: SandboxWorkspaceWrite | null; "service_tier"?: string | null; "tools"?: ToolsV2 | null; "web_search"?: WebSearchMode | null; [key: string]: unknown };
|
||||
export type ConfigBatchWriteParams = { "edits": Array<ConfigEdit>; "expectedVersion"?: string | null; "filePath"?: string | null; "reloadUserConfig"?: boolean };
|
||||
export type ConfigEdit = { "keyPath": string; "mergeStrategy": MergeStrategy; "value": unknown };
|
||||
export type ConfigLayer = { "config": unknown; "disabledReason"?: string | null; "name": ConfigLayerSource; "version": string };
|
||||
export type ConfigLayerMetadata = { "name": ConfigLayerSource; "version": string };
|
||||
export type ConfigLayerSource = { "file": AbsolutePathBuf; "type": "packagedDefaults" } | { "domain": string; "key": string; "type": "mdm" } | { "file": AbsolutePathBuf; "type": "system" } | { "id": string; "name": string; "type": "enterpriseManaged" } | { "file": AbsolutePathBuf; "profile"?: string | null; "type": "user" } | { "dotCodexFolder": AbsolutePathBuf; "type": "project" } | { "type": "sessionFlags" } | { "file": AbsolutePathBuf; "type": "legacyManagedConfigTomlFromFile" } | { "type": "legacyManagedConfigTomlFromMdm" };
|
||||
export type ConfigReadParams = { "cwd"?: string | null; "includeLayers"?: boolean };
|
||||
export type ConfigReadResponse = { "config": Config; "layers"?: Array<ConfigLayer> | null; "origins": { [key: string]: ConfigLayerMetadata } };
|
||||
export type ConfigValueWriteParams = { "expectedVersion"?: string | null; "filePath"?: string | null; "keyPath": string; "mergeStrategy": MergeStrategy; "value": unknown };
|
||||
export type ConfigWriteResponse = { "filePath": AbsolutePathBuf; "overriddenMetadata"?: OverriddenMetadata | null; "status": WriteStatus; "version": string };
|
||||
export type ContextCompactedNotification = { "threadId": string; "turnId": string };
|
||||
export type CyberAccessProgram = "standard" | "daybreakBlue" | "daybreakRed";
|
||||
export type DynamicToolCallOutputContentItem = { "text": string; "type": "inputText" } | { "imageUrl": string; "type": "inputImage" } | { "audioUrl": string; "type": "inputAudio" };
|
||||
export type DynamicToolCallParams = { "arguments": unknown; "callId": string; "namespace"?: string | null; "threadId": string; "tool": string; "turnId": string };
|
||||
export type DynamicToolCallResponse = { "contentItems": Array<DynamicToolCallOutputContentItem>; "success": boolean };
|
||||
export type DynamicToolCallStatus = "inProgress" | "completed" | "failed";
|
||||
export type ErrorNotification = { "error": TurnError; "threadId": string; "turnId": string; "willRetry": boolean };
|
||||
export type FileChangeApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel";
|
||||
export type FileChangeOutputDeltaNotification = { "delta": string; "itemId": string; "threadId": string; "turnId": string };
|
||||
export type FileChangePatchUpdatedNotification = { "changes": Array<FileUpdateChange>; "itemId": string; "threadId": string; "turnId": string };
|
||||
export type FileChangeRequestApprovalParams = { "grantRoot"?: string | null; "itemId": string; "reason"?: string | null; "startedAtMs": number; "threadId": string; "turnId": string };
|
||||
export type FileChangeRequestApprovalResponse = { "decision": FileChangeApprovalDecision };
|
||||
export type FileSystemAccessMode = "read" | "write" | "deny";
|
||||
export type FileSystemPath = { "path": LegacyAppPathString; "type": "path" } | { "pattern": string; "type": "glob_pattern" } | { "value": FileSystemSpecialPath; "type": "special" };
|
||||
export type FileSystemSandboxEntry = { "access": FileSystemAccessMode; "path": FileSystemPath };
|
||||
export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "subpath"?: LegacyAppPathString | null; "kind": "project_roots" } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "path": string; "subpath"?: LegacyAppPathString | null; "kind": "unknown" };
|
||||
export type FileUpdateChange = { "diff": string; "kind": PatchChangeKind; "path": string };
|
||||
export type ForcedChatgptWorkspaceIds = string | Array<string>;
|
||||
export type ForcedLoginMethod = "chatgpt" | "api";
|
||||
export type FsChangedNotification = { "changedPaths": Array<AbsolutePathBuf>; "watchId": string };
|
||||
export type FsCopyParams = { "destinationPath": AbsolutePathBuf; "recursive"?: boolean; "sourcePath": AbsolutePathBuf };
|
||||
export type FsCopyResponse = Record<string, never>;
|
||||
export type FsCreateDirectoryParams = { "path": AbsolutePathBuf; "recursive"?: boolean | null };
|
||||
export type FsCreateDirectoryResponse = Record<string, never>;
|
||||
export type FsGetMetadataParams = { "path": AbsolutePathBuf };
|
||||
export type FsGetMetadataResponse = { "createdAtMs": number; "isDirectory": boolean; "isFile": boolean; "isSymlink": boolean; "modifiedAtMs": number };
|
||||
export type FsReadDirectoryEntry = { "fileName": string; "isDirectory": boolean; "isFile": boolean };
|
||||
export type FsReadDirectoryParams = { "path": AbsolutePathBuf };
|
||||
export type FsReadDirectoryResponse = { "entries": Array<FsReadDirectoryEntry> };
|
||||
export type FsReadFileParams = { "path": AbsolutePathBuf };
|
||||
export type FsReadFileResponse = { "dataBase64": string };
|
||||
export type FsRemoveParams = { "force"?: boolean | null; "path": AbsolutePathBuf; "recursive"?: boolean | null };
|
||||
export type FsRemoveResponse = Record<string, never>;
|
||||
export type FsUnwatchParams = { "watchId": string };
|
||||
export type FsUnwatchResponse = Record<string, never>;
|
||||
export type FsWatchParams = { "path": AbsolutePathBuf; "watchId": string };
|
||||
export type FsWatchResponse = { "path": AbsolutePathBuf };
|
||||
export type FsWriteFileParams = { "dataBase64": string; "path": AbsolutePathBuf };
|
||||
export type FsWriteFileResponse = Record<string, never>;
|
||||
export type FunctionCallOutputBody = string | Array<FunctionCallOutputContentItem>;
|
||||
export type FunctionCallOutputContentItem = { "text": string; "type": "input_text" } | { "detail"?: ImageDetail | null; "type": "input_image" } & FunctionCallOutputContentItemInputImageKind | { "audio_url": string; "type": "input_audio" } | { "encrypted_content": string; "type": "encrypted_content" };
|
||||
export type FunctionCallOutputContentItemInputImageKind = { "image_url": string } | { "file_id": string };
|
||||
export type GitInfo = { "branch"?: string | null; "originUrl"?: string | null; "sha"?: string | null };
|
||||
export type GrantedPermissionProfile = { "fileSystem"?: AdditionalFileSystemPermissions | null; "network"?: AdditionalNetworkPermissions | null };
|
||||
export type HookErrorInfo = { "message": string; "path": string };
|
||||
export type HookEventName = "preModelRequest" | "postModelRequest" | "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop" | "interrupt";
|
||||
export type HookMetadata = { "additionalContextLimit"?: number | null; "currentHash": string; "displayOrder": number; "enabled": boolean; "environmentId"?: string | null; "eventName": HookEventName; "isManaged": boolean; "key": string; "matcher"?: string | null; "pluginId"?: string | null; "source": HookSource; "sourcePath": AbsolutePathBuf; "statusMessage"?: string | null; "timeoutSec": number; "trustStatus": HookTrustStatus } & HookMetadataKind;
|
||||
export type HookMetadataKind = { "async"?: boolean; "command": string; "handlerType": "command" } | { "server": string; "tool": string; "handlerType": "mcpTool" } | { "handlerType": "prompt" } | { "handlerType": "agent" };
|
||||
export type HookPromptFragment = { "hookRunId": string; "text": string };
|
||||
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "cloudManagedConfig" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
|
||||
export type HookTrustStatus = "managed" | "untrusted" | "trusted" | "modified";
|
||||
export type HooksListEntry = { "cwd": string; "errors": Array<HookErrorInfo>; "hooks": Array<HookMetadata>; "warnings": Array<string> };
|
||||
export type HooksListParams = { "cwds"?: Array<string>; "threadId"?: string | null };
|
||||
export type HooksListResponse = { "data": Array<HooksListEntry> };
|
||||
export type ImageDetail = "auto" | "low" | "high" | "original";
|
||||
export type ImageGenerationFailure = { "limitId": string; "resetsAt"?: number | null; "type": "usageLimitExceeded" };
|
||||
export type ImageGenerationInputImage = { "path": AbsolutePathBuf; "sha256": string };
|
||||
export type InitializeCapabilities = { "experimentalApi"?: boolean; "explicitGatewayOauth"?: boolean; "extensions"?: { [key: string]: unknown } | null; "mcpServerOpenaiFormElicitation"?: boolean; "optOutNotificationMethods"?: Array<string> | null; "requestAttestation"?: boolean };
|
||||
export type InitializeParams = { "capabilities"?: InitializeCapabilities | null; "clientInfo": ClientInfo };
|
||||
export type InitializeResponse = { "codexHome": AbsolutePathBuf; "platformFamily": string; "platformOs": string; "userAgent": string };
|
||||
export type InputModality = "text" | "image" | "audio";
|
||||
export type ItemCompletedNotification = { "completedAtMs": number; "item": ThreadItem; "threadId": string; "turnId": string };
|
||||
export type ItemStartedNotification = { "item": ThreadItem; "startedAtMs": number; "threadId": string; "turnId": string };
|
||||
export type LegacyAppPathString = string;
|
||||
export type ListMcpServerStatusParams = { "cursor"?: string | null; "detail"?: McpServerStatusDetail | null; "limit"?: number | null; "serverName"?: string | null; "threadId"?: string | null };
|
||||
export type ListMcpServerStatusResponse = { "data": Array<McpServerStatus>; "nextCursor"?: string | null };
|
||||
export type McpAppDisplayMode = "inline" | "fullscreen";
|
||||
export type McpAppUi = { "preferredModelDisplayMode": McpAppDisplayMode; "resourceUri": string };
|
||||
export type McpAuthStatus = "unknown" | "unsupported" | "notLoggedIn" | "bearerToken" | "oAuth";
|
||||
export type McpServerConnectionStatus = "notStarted" | "starting" | "connected" | "authenticationRequired" | "failed" | "cancelled" | "disabled";
|
||||
export type McpServerInfo = { "description"?: string | null; "icons"?: Array<unknown> | null; "name": string; "title"?: string | null; "version": string; "websiteUrl"?: string | null };
|
||||
export type McpServerStatus = { "authStatus": McpAuthStatus; "httpOrigin"?: string | null; "name": string; "pluginId"?: string | null; "resourceTemplates": Array<ResourceTemplate>; "resources": Array<Resource>; "runtimeStatus"?: McpServerConnectionStatus | null; "serverCapabilities"?: unknown; "serverInfo"?: McpServerInfo | null; "toolListMeta"?: unknown; "tools": { [key: string]: Tool }; "toolsError"?: string | null };
|
||||
export type McpServerStatusDetail = "full" | "toolsAndAuthOnly";
|
||||
export type McpToolCallAppContext = { "actionName"?: string | null; "appName"?: string | null; "connectorId": string; "linkId"?: string | null; "resourceUri"?: string | null };
|
||||
export type McpToolCallError = { "message": string };
|
||||
export type McpToolCallProgressNotification = { "itemId": string; "message": string; "threadId": string; "turnId": string };
|
||||
export type McpToolCallResult = { "_meta"?: unknown; "content": Array<unknown>; "structuredContent"?: unknown };
|
||||
export type McpToolCallStatus = "inProgress" | "completed" | "failed";
|
||||
export type MemoryCitation = { "entries": Array<MemoryCitationEntry>; "threadIds": Array<string> };
|
||||
export type MemoryCitationEntry = { "lineEnd": number; "lineStart": number; "note": string; "path": string };
|
||||
export type MergeStrategy = "replace" | "upsert";
|
||||
export type MessagePhase = "commentary" | "partial_answer" | "final_answer";
|
||||
export type MisalignmentErrorDetails = { "detailedExplanation"?: string | null; "errorType"?: string | null; "reviewTarget"?: string | null; "steer"?: MisalignmentSteer | null };
|
||||
export type MisalignmentSteer = { "message": string };
|
||||
export type ModeKind = "plan" | "default";
|
||||
export type Model = { "additionalSpeedTiers"?: Array<string>; "availabilityNux"?: ModelAvailabilityNux | null; "availableAccessPrograms"?: ModelAccessPrograms | null; "defaultReasoningEffort": ReasoningEffort; "defaultServiceTier"?: string | null; "description": string; "displayName": string; "hidden": boolean; "id": string; "inputModalities"?: Array<InputModality>; "isDefault": boolean; "model": string; "modelSpecialty"?: string | null; "multiAgentVersion"?: MultiAgentVersion | null; "serviceTiers"?: Array<ModelServiceTier>; "supportedReasoningEfforts": Array<ReasoningEffortOption>; "supportsPersonality"?: boolean; "upgrade"?: string | null; "upgradeInfo"?: ModelUpgradeInfo | null };
|
||||
export type ModelAccessPrograms = { "cyber": Array<CyberAccessProgram> };
|
||||
export type ModelAvailabilityNux = { "message": string };
|
||||
export type ModelListParams = { "cursor"?: string | null; "includeHidden"?: boolean | null; "limit"?: number | null };
|
||||
export type ModelListResponse = { "data": Array<Model>; "nextCursor"?: string | null };
|
||||
export type ModelServiceTier = { "description": string; "id": string; "name": string };
|
||||
export type ModelUpgradeInfo = { "migrationMarkdown"?: string | null; "model": string; "modelLink"?: string | null; "retirementAt"?: number | null; "upgradeCopy"?: string | null };
|
||||
export type MultiAgentVersion = "disabled" | "v1" | "v2";
|
||||
export type NetworkAccess = "restricted" | "enabled";
|
||||
export type NetworkApprovalContext = { "host": string; "protocol": NetworkApprovalProtocol };
|
||||
export type NetworkApprovalProtocol = "http" | "https" | "socks5Tcp" | "socks5Udp";
|
||||
export type NetworkPolicyAmendment = { "action": NetworkPolicyRuleAction; "host": string };
|
||||
export type NetworkPolicyRuleAction = "allow" | "deny";
|
||||
export type NonSteerableTurnKind = "review" | "compact";
|
||||
export type OverriddenMetadata = { "effectiveValue": unknown; "message": string; "overridingLayer": ConfigLayerMetadata };
|
||||
export type PatchApplyStatus = "inProgress" | "completed" | "failed" | "declined";
|
||||
export type PatchChangeKind = { "type": "add" } | { "type": "delete" } | { "move_path"?: string | null; "type": "update" };
|
||||
export type PermissionGrantScope = "turn" | "session";
|
||||
export type PermissionsRequestApprovalParams = { "cwd": LegacyAppPathString; "environmentId"?: string | null; "itemId": string; "permissions": RequestPermissionProfile; "reason"?: string | null; "startedAtMs": number; "threadId": string; "turnId": string };
|
||||
export type PermissionsRequestApprovalResponse = { "permissions": GrantedPermissionProfile; "scope"?: PermissionGrantScope; "strictAutoReview"?: boolean | null };
|
||||
export type Personality = "none" | "friendly" | "pragmatic";
|
||||
export type PlanDeltaNotification = { "delta": string; "itemId": string; "threadId": string; "turnId": string };
|
||||
export type ReasoningEffort = string;
|
||||
export type ReasoningEffortOption = { "description": string; "reasoningEffort": ReasoningEffort };
|
||||
export type ReasoningSummary = "auto" | "concise" | "detailed" | "none";
|
||||
export type ReasoningSummaryPartAddedNotification = { "itemId": string; "summaryIndex": number; "threadId": string; "turnId": string };
|
||||
export type ReasoningSummaryTextDeltaNotification = { "delta": string; "itemId": string; "summaryIndex": number; "threadId": string; "turnId": string };
|
||||
export type ReasoningTextDeltaNotification = { "contentIndex": number; "delta": string; "itemId": string; "threadId": string; "turnId": string };
|
||||
export type RequestId = string | number;
|
||||
export type RequestPermissionProfile = { "fileSystem"?: AdditionalFileSystemPermissions | null; "network"?: AdditionalNetworkPermissions | null };
|
||||
export type Resource = { "_meta"?: unknown; "annotations"?: unknown; "description"?: string | null; "icons"?: Array<unknown> | null; "mimeType"?: string | null; "name": string; "size"?: number | null; "title"?: string | null; "uri": string };
|
||||
export type ResourceTemplate = { "annotations"?: unknown; "description"?: string | null; "mimeType"?: string | null; "name": string; "title"?: string | null; "uriTemplate": string };
|
||||
export type ReviewDelivery = "inline";
|
||||
export type ReviewStartParams = { "delivery"?: ReviewDelivery | null; "target": ReviewTarget; "threadId": string };
|
||||
export type ReviewStartResponse = { "reviewThreadId": string; "turn": Turn };
|
||||
export type ReviewTarget = { "type": "uncommittedChanges" } | { "branch": string; "type": "baseBranch" } | { "sha": string; "title"?: string | null; "type": "commit" } | { "instructions": string; "type": "custom" };
|
||||
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
||||
export type SandboxPolicy = { "type": "dangerFullAccess" } | { "networkAccess"?: boolean; "type": "readOnly" } | { "networkAccess"?: NetworkAccess; "type": "externalSandbox" } | { "excludeSlashTmp"?: boolean; "excludeTmpdirEnvVar"?: boolean; "networkAccess"?: boolean; "writableRoots"?: Array<AbsolutePathBuf>; "type": "workspaceWrite" };
|
||||
export type SandboxWorkspaceWrite = { "exclude_slash_tmp"?: boolean; "exclude_tmpdir_env_var"?: boolean; "network_access"?: boolean; "writable_roots"?: Array<string> };
|
||||
export type ServerRequestResolvedNotification = { "requestId": RequestId; "threadId": string };
|
||||
export type SessionSource = "cli" | "vscode" | "exec" | "appServer" | "unknown" | { "custom": string } | { "subAgent": SubAgentSource };
|
||||
export type Settings = { "developer_instructions"?: string | null; "model": string; "reasoning_effort"?: ReasoningEffort | null };
|
||||
export type SkillDependencies = { "tools": Array<SkillToolDependency> };
|
||||
export type SkillErrorInfo = { "message": string; "path": string };
|
||||
export type SkillInterface = { "brandColor"?: string | null; "defaultPrompt"?: string | null; "displayName"?: string | null; "iconLarge"?: AbsolutePathBuf | null; "iconLargeUrl"?: string | null; "iconSmall"?: AbsolutePathBuf | null; "iconSmallUrl"?: string | null; "shortDescription"?: string | null };
|
||||
export type SkillMetadata = { "dependencies"?: SkillDependencies | null; "description": string; "enabled": boolean; "interface"?: SkillInterface | null; "name": string; "path": LegacyAppPathString; "pluginId"?: string | null; "scope": SkillScope; "shortDescription"?: string | null };
|
||||
export type SkillScope = "user" | "repo" | "system" | "admin";
|
||||
export type SkillToolDependency = { "command"?: string | null; "description"?: string | null; "transport"?: string | null; "type": string; "url"?: string | null; "value": string };
|
||||
export type SkillsChangedNotification = Record<string, never>;
|
||||
export type SkillsListEntry = { "cwd": string; "errors": Array<SkillErrorInfo>; "skills": Array<SkillMetadata> };
|
||||
export type SkillsListParams = { "cwds"?: Array<string>; "forceReload"?: boolean };
|
||||
export type SkillsListResponse = { "data": Array<SkillsListEntry> };
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type SubAgentActivityKind = "started" | "interacted" | "interrupted" | "completed";
|
||||
export type SubAgentSource = "review" | "compact" | "memory_consolidation" | { "thread_spawn": { "agent_nickname"?: string | null; "agent_path"?: AgentPath | null; "agent_role"?: string | null; "depth": number; "parent_thread_id": ThreadId } } | { "other": string };
|
||||
export type TerminalInteractionNotification = { "itemId": string; "processId": string; "stdin": string; "threadId": string; "turnId": string };
|
||||
export type TextElement = { "byteRange": ByteRange; "placeholder"?: string | null };
|
||||
export type Thread = { "agentNickname"?: string | null; "agentRole"?: string | null; "cliVersion": string; "createdAt": number; "cwd": AbsolutePathBuf; "ephemeral": boolean; "forkedFromId"?: string | null; "gitInfo"?: GitInfo | null; "historyMode"?: ThreadHistoryMode; "id": string; "model"?: string | null; "modelProvider": string; "name"?: string | null; "originator"?: string | null; "parentThreadId"?: string | null; "path"?: string | null; "preview": string; "projectId": string | null; "reasoningEffort"?: ReasoningEffort | null; "recencyAt"?: number | null; "section"?: ThreadSection | null; "sectionEnteredAt"?: number | null; "sessionId": string; "source": SessionSource; "status": ThreadStatus; "threadSource"?: ThreadSource | null; "turns": Array<Turn>; "updatedAt": number; "executor"?: ThreadExecutor | null; "environment"?: ThreadPlacement | null };
|
||||
export type ThreadActiveFlag = "waitingOnApproval" | "waitingOnUserInput";
|
||||
export type ThreadArchiveParams = { "threadId": string };
|
||||
export type ThreadArchiveResponse = Record<string, never>;
|
||||
export type ThreadArchivedNotification = { "threadId": string };
|
||||
export type ThreadClosedNotification = { "threadId": string };
|
||||
export type ThreadCompactStartParams = { "threadId": string };
|
||||
export type ThreadCompactStartResponse = Record<string, never>;
|
||||
export type ThreadDeleteParams = { "threadId": string };
|
||||
export type ThreadDeleteResponse = Record<string, never>;
|
||||
export type ThreadDeletedNotification = { "threadId": string };
|
||||
export type ThreadExecutor = { "kind": string; "agentId": string; "agentName"?: string | null };
|
||||
export type ThreadForkParams = { "approvalPolicy"?: AskForApproval | null; "approvalsReviewer"?: ApprovalsReviewer | null; "baseInstructions"?: string | null; "config"?: { [key: string]: unknown } | null; "cwd"?: string | null; "developerInstructions"?: string | null; "ephemeral"?: boolean; "excludeTurns"?: boolean; "lastTurnId"?: string | null; "model"?: string | null; "modelProvider"?: string | null; "sandbox"?: SandboxMode | null; "serviceTier"?: string | null; "threadId": string; "threadSource"?: ThreadSource | null };
|
||||
export type ThreadForkResponse = { "approvalPolicy": AskForApproval; "approvalsReviewer": ApprovalsReviewer; "cwd": AbsolutePathBuf; "disabledPluginIds"?: Array<string>; "instructionSources"?: Array<LegacyAppPathString>; "model": string; "modelProvider": string; "reasoningEffort"?: ReasoningEffort | null; "sandbox": SandboxPolicy; "serviceTier"?: string | null; "thread": Thread };
|
||||
export type ThreadGoal = { "createdAt": number; "objective": string; "status": ThreadGoalStatus; "threadId": string; "timeUsedSeconds": number; "tokenBudget"?: number | null; "tokensUsed": number; "updatedAt": number };
|
||||
export type ThreadGoalClearParams = { "origin"?: ThreadGoalMutationOrigin | null; "threadId": string };
|
||||
export type ThreadGoalClearResponse = { "cleared": boolean };
|
||||
export type ThreadGoalClearedNotification = { "threadId": string };
|
||||
export type ThreadGoalGetParams = { "threadId": string };
|
||||
export type ThreadGoalGetResponse = { "goal"?: ThreadGoal | null };
|
||||
export type ThreadGoalMutationOrigin = "user" | "automatic";
|
||||
export type ThreadGoalSetParams = { "objective"?: string | null; "origin"?: ThreadGoalMutationOrigin | null; "status"?: ThreadGoalStatus | null; "threadId": string; "tokenBudget"?: number | null };
|
||||
export type ThreadGoalSetResponse = { "goal": ThreadGoal };
|
||||
export type ThreadGoalStatus = "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete";
|
||||
export type ThreadGoalUpdatedNotification = { "goal": ThreadGoal; "threadId": string; "turnId"?: string | null };
|
||||
export type ThreadHistoryMode = "legacy" | "paginated";
|
||||
export type ThreadId = string;
|
||||
export type ThreadItem = { "completedAtMs"?: number | null; "startedAtMs"?: number | null } & ThreadItemKind;
|
||||
export type ThreadItemEntry = { "completedAtMs"?: number | null; "item": ThreadItem; "startedAtMs"?: number | null; "turnId": string };
|
||||
export type ThreadItemKind = { "clientId"?: string | null; "content": Array<UserInput>; "id": string; "type": "userMessage" } | { "fragments": Array<HookPromptFragment>; "id": string; "type": "hookPrompt" } | { "delivery"?: AgentMessageDelivery | null; "id": string; "memoryCitation"?: MemoryCitation | null; "phase"?: MessagePhase | null; "questions"?: Array<AsyncUserInputQuestion> | null; "text": string; "type": "agentMessage" } | { "id": string; "name": string; "namespace"?: string | null; "output": FunctionCallOutputBody; "type": "functionCallOutput" } | { "id": string; "text": string; "type": "plan" } | { "content"?: Array<string>; "id": string; "summary"?: Array<string>; "type": "reasoning" } | { "aggregatedOutput"?: string | null; "command": string; "commandActions": Array<CommandAction>; "cwd": LegacyAppPathString; "durationMs"?: number | null; "exitCode"?: number | null; "id": string; "pluginId"?: string | null; "processId"?: string | null; "scriptPath"?: string | null; "source"?: CommandExecutionSource; "status": CommandExecutionStatus; "type": "commandExecution" } | { "changes": Array<FileUpdateChange>; "id": string; "status": PatchApplyStatus; "type": "fileChange" } | { "appContext"?: McpToolCallAppContext | null; "arguments": unknown; "durationMs"?: number | null; "error"?: McpToolCallError | null; "id": string; "mcpAppResourceUri"?: string | null; "mcpAppUi"?: McpAppUi | null; "pluginId"?: string | null; "readOnlyHint"?: boolean | null; "result"?: McpToolCallResult | null; "server": string; "status": McpToolCallStatus; "tool": string; "type": "mcpToolCall" } | { "arguments": unknown; "contentItems"?: Array<DynamicToolCallOutputContentItem> | null; "durationMs"?: number | null; "id": string; "namespace"?: string | null; "status": DynamicToolCallStatus; "success"?: boolean | null; "tool": string; "type": "dynamicToolCall" } | { "agentsStates": { [key: string]: CollabAgentState }; "id": string; "model"?: string | null; "prompt"?: string | null; "reasoningEffort"?: ReasoningEffort | null; "receiverThreadIds": Array<string>; "senderThreadId": string; "status": CollabAgentToolCallStatus; "tool": CollabAgentTool; "type": "collabAgentToolCall" } | { "agentPath": string; "agentThreadId": string; "id": string; "kind": SubAgentActivityKind; "model"?: string | null; "reasoningEffort"?: ReasoningEffort | null; "type": "subAgentActivity" } | { "action"?: WebSearchAction | null; "id": string; "query": string; "results"?: Array<unknown> | null; "type": "webSearch" } | { "id": string; "path": LegacyAppPathString; "type": "imageView" } | { "durationMs": number; "id": string; "type": "sleep" } | { "failure"?: ImageGenerationFailure | null; "generationId"?: string | null; "id": string; "inputImages"?: Array<ImageGenerationInputImage>; "revisedPrompt"?: string | null; "savedPath"?: AbsolutePathBuf | null; "status": string; "transparentBackground"?: boolean | null; "type": "imageGeneration" } & ThreadItemKindImageGenerationKind | { "id": string; "review": string; "type": "enteredReviewMode" } | { "id": string; "review": string; "type": "exitedReviewMode" } | { "id": string; "type": "contextCompaction" };
|
||||
export type ThreadItemKindImageGenerationKind = { "fileId": string } | { "result": string };
|
||||
export type ThreadItemsListAnchor = { "itemId": string; "type": "item" };
|
||||
export type ThreadItemsListCursor = string | ThreadItemsListAnchor;
|
||||
export type ThreadItemsListParams = { "cursor"?: ThreadItemsListCursor | null; "limit"?: number | null; "sortDirection"?: SortDirection | null; "threadId": string; "turnId"?: string | null };
|
||||
export type ThreadItemsListResponse = { "backwardsCursor"?: string | null; "data": Array<ThreadItemEntry>; "nextCursor"?: string | null };
|
||||
export type ThreadListCwdFilter = string | Array<string>;
|
||||
export type ThreadListParams = { "archived"?: boolean | null; "cursor"?: string | null; "cwd"?: ThreadListCwdFilter | null; "excludedThreadIds"?: Array<string> | null; "limit"?: number | null; "modelProviders"?: Array<string> | null; "originators"?: Array<string> | null; "searchTerm"?: string | null; "sectionId"?: string | null; "sortDirection"?: SortDirection | null; "sortKey"?: ThreadSortKey | null; "sourceKinds"?: Array<ThreadSourceKind> | null; "useStateDbOnly"?: boolean };
|
||||
export type ThreadListResponse = { "backwardsCursor"?: string | null; "data": Array<Thread>; "nextCursor"?: string | null };
|
||||
export type ThreadLoadedListParams = { "cursor"?: string | null; "limit"?: number | null };
|
||||
export type ThreadLoadedListResponse = { "data": Array<string>; "nextCursor"?: string | null };
|
||||
export type ThreadNameUpdatedNotification = { "threadId": string; "threadName"?: string | null };
|
||||
export type ThreadPlacement = { "id": string; "providerKind": string };
|
||||
export type ThreadReadParams = { "includeTurns"?: boolean; "threadId": string };
|
||||
export type ThreadReadResponse = { "thread": Thread };
|
||||
export type ThreadResumeParams = { "approvalPolicy"?: AskForApproval | null; "approvalsReviewer"?: ApprovalsReviewer | null; "baseInstructions"?: string | null; "config"?: { [key: string]: unknown } | null; "cwd"?: string | null; "developerInstructions"?: string | null; "excludeTurns"?: boolean; "model"?: string | null; "modelProvider"?: string | null; "personality"?: Personality | null; "sandbox"?: SandboxMode | null; "serviceTier"?: string | null; "threadId": string };
|
||||
export type ThreadResumeResponse = { "approvalPolicy": AskForApproval; "approvalsReviewer": ApprovalsReviewer; "collaborationMode"?: CollaborationMode | null; "cwd": AbsolutePathBuf; "disabledPluginIds"?: Array<string>; "instructionSources"?: Array<LegacyAppPathString>; "itemsBackwardsCursor"?: string | null; "model": string; "modelProvider": string; "reasoningEffort"?: ReasoningEffort | null; "sandbox": SandboxPolicy; "serviceTier"?: string | null; "thread": Thread; "turnsBackwardsCursor"?: string | null };
|
||||
export type ThreadRevertParams = { "beforeTurnId": string; "threadId": string };
|
||||
export type ThreadRevertResponse = { "itemsBackwardsCursor"?: string | null; "thread": Thread; "turnsBackwardsCursor"?: string | null };
|
||||
export type ThreadRevertedNotification = { "threadId": string };
|
||||
export type ThreadSection = { "appearance"?: ThreadSectionAppearance | null; "id": string; "name": string };
|
||||
export type ThreadSectionAppearance = { "color"?: string | null; "icon"?: string | null };
|
||||
export type ThreadSetNameParams = { "name": string; "threadId": string };
|
||||
export type ThreadSetNameResponse = Record<string, never>;
|
||||
export type ThreadShellCommandParams = { "command": string; "threadId": string; "timeoutMs"?: number | null };
|
||||
export type ThreadShellCommandResponse = Record<string, never>;
|
||||
export type ThreadSortKey = "created_at" | "updated_at" | "recency_at" | "section_position";
|
||||
export type ThreadSource = string;
|
||||
export type ThreadSourceKind = "cli" | "vscode" | "exec" | "appServer" | "subAgent" | "subAgentReview" | "subAgentCompact" | "subAgentThreadSpawn" | "subAgentOther" | "unknown";
|
||||
export type ThreadStartParams = { "approvalPolicy"?: AskForApproval | null; "approvalsReviewer"?: ApprovalsReviewer | null; "baseInstructions"?: string | null; "config"?: { [key: string]: unknown } | null; "cwd"?: string | null; "developerInstructions"?: string | null; "ephemeral"?: boolean | null; "model"?: string | null; "modelProvider"?: string | null; "personality"?: Personality | null; "sandbox"?: SandboxMode | null; "serviceName"?: string | null; "serviceTier"?: string | null; "sessionStartSource"?: ThreadStartSource | null; "threadSource"?: ThreadSource | null };
|
||||
export type ThreadStartResponse = { "approvalPolicy": AskForApproval; "approvalsReviewer": ApprovalsReviewer; "cwd": AbsolutePathBuf; "disabledPluginIds"?: Array<string>; "instructionSources"?: Array<LegacyAppPathString>; "model": string; "modelProvider": string; "reasoningEffort"?: ReasoningEffort | null; "sandbox": SandboxPolicy; "serviceTier"?: string | null; "thread": Thread };
|
||||
export type ThreadStartSource = "startup" | "clear";
|
||||
export type ThreadStartedNotification = { "thread": Thread };
|
||||
export type ThreadStatus = { "type": "notLoaded" } | { "type": "idle" } | { "type": "systemError" } | { "activeFlags": Array<ThreadActiveFlag>; "type": "active" };
|
||||
export type ThreadStatusChangedNotification = { "status": ThreadStatus; "threadId": string };
|
||||
export type ThreadTokenUsage = { "last": TokenUsageBreakdown; "modelContextWindow"?: number | null; "total": TokenUsageBreakdown };
|
||||
export type ThreadTokenUsageUpdatedNotification = { "threadId": string; "tokenUsage": ThreadTokenUsage; "turnId": string };
|
||||
export type ThreadTurnsListParams = { "cursor"?: string | null; "itemsView"?: TurnItemsView | null; "limit"?: number | null; "sortDirection"?: SortDirection | null; "threadId": string };
|
||||
export type ThreadTurnsListResponse = { "backwardsCursor"?: string | null; "data": Array<Turn>; "nextCursor"?: string | null };
|
||||
export type ThreadUnarchiveParams = { "threadId": string };
|
||||
export type ThreadUnarchiveResponse = { "thread": Thread };
|
||||
export type ThreadUnarchivedNotification = { "threadId": string };
|
||||
export type ThreadUnsubscribeParams = { "threadId": string };
|
||||
export type ThreadUnsubscribeResponse = { "status": ThreadUnsubscribeStatus };
|
||||
export type ThreadUnsubscribeStatus = "notLoaded" | "notSubscribed" | "unsubscribed";
|
||||
export type TokenUsageBreakdown = { "cacheWriteInputTokens"?: number; "cachedInputTokens": number; "inputTokens": number; "outputTokens": number; "reasoningOutputTokens": number; "totalTokens": number };
|
||||
export type Tool = { "_meta"?: unknown; "annotations"?: unknown; "description"?: string | null; "icons"?: Array<unknown> | null; "inputSchema": unknown; "name": string; "outputSchema"?: unknown; "title"?: string | null };
|
||||
export type ToolRequestUserInputAnswer = { "answers": Array<string> };
|
||||
export type ToolRequestUserInputOption = { "description": string; "label": string };
|
||||
export type ToolRequestUserInputParams = { "autoResolutionMs"?: number | null; "isBlocking": boolean; "itemId": string; "questions": Array<ToolRequestUserInputQuestion>; "threadId": string; "turnId": string };
|
||||
export type ToolRequestUserInputQuestion = { "header": string; "id": string; "isOther"?: boolean; "isSecret"?: boolean; "options"?: Array<ToolRequestUserInputOption> | null; "question": string };
|
||||
export type ToolRequestUserInputResponse = { "answers": { [key: string]: ToolRequestUserInputAnswer } };
|
||||
export type ToolsV2 = { "web_search"?: WebSearchToolConfig | null };
|
||||
export type Turn = { "completedAt"?: number | null; "durationMs"?: number | null; "error"?: TurnError | null; "id": string; "items": Array<ThreadItem>; "itemsView"?: TurnItemsView; "rootTurnId"?: string | null; "startedAt"?: number | null; "status": TurnStatus };
|
||||
export type TurnCompletedNotification = { "threadId": string; "turn": Turn };
|
||||
export type TurnDiffUpdatedNotification = { "diff": string; "threadId": string; "turnId": string };
|
||||
export type TurnError = { "additionalDetails"?: string | null; "codexErrorInfo"?: CodexErrorInfo | null; "message": string; "misalignment"?: MisalignmentErrorDetails | null };
|
||||
export type TurnInterruptParams = { "threadId": string; "turnId": string };
|
||||
export type TurnInterruptResponse = Record<string, never>;
|
||||
export type TurnItemsView = "notLoaded" | "summary" | "full";
|
||||
export type TurnPlanStep = { "status": TurnPlanStepStatus; "step": string };
|
||||
export type TurnPlanStepStatus = "pending" | "inProgress" | "completed";
|
||||
export type TurnPlanUpdatedNotification = { "explanation"?: string | null; "plan": Array<TurnPlanStep>; "threadId": string; "turnId": string };
|
||||
export type TurnStartParams = { "approvalPolicy"?: AskForApproval | null; "approvalsReviewer"?: ApprovalsReviewer | null; "clientUserMessageId"?: string | null; "cwd"?: string | null; "disabledPluginIds"?: Array<string> | null; "effort"?: ReasoningEffort | null; "input": Array<UserInput>; "model"?: string | null; "outputSchema"?: unknown; "parentTurnId"?: string | null; "personality"?: Personality | null; "rootTurnId"?: string | null; "sandboxPolicy"?: SandboxPolicy | null; "serviceTier"?: string | null; "serviceTierForTurn"?: string | null; "summary"?: ReasoningSummary | null; "threadId": string; "toolOutput"?: TurnToolOutput | null; "turnTrigger"?: string | null };
|
||||
export type TurnStartResponse = { "turn": Turn };
|
||||
export type TurnStartedNotification = { "threadId": string; "turn": Turn };
|
||||
export type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress";
|
||||
export type TurnSteerParams = { "clientUserMessageId"?: string | null; "expectedTurnId": string; "input": Array<UserInput>; "threadId": string };
|
||||
export type TurnSteerResponse = { "turnId": string };
|
||||
export type TurnToolOutput = { "name": string; "namespace"?: string | null; "output": FunctionCallOutputBody; "retain"?: boolean };
|
||||
export type UserInput = { "text": string; "text_elements"?: Array<TextElement>; "type": "text" } | { "detail"?: ImageDetail | null; "type": "image" } & UserInputImageKind | { "detail"?: ImageDetail | null; "path": string; "type": "localImage" } | { "url": string; "type": "audio" } | { "path": string; "type": "localAudio" } | { "name": string; "path": string; "type": "skill" } | { "name": string; "path": string; "type": "mention" };
|
||||
export type UserInputImageKind = { "url": string } | { "fileId": string };
|
||||
export type Verbosity = "low" | "medium" | "high";
|
||||
export type WebSearchAction = { "queries"?: Array<string> | null; "query"?: string | null; "type": "search" } | { "url"?: string | null; "type": "openPage" } | { "pattern"?: string | null; "url"?: string | null; "type": "findInPage" } | { "type": "other" };
|
||||
export type WebSearchContextSize = "low" | "medium" | "high";
|
||||
export type WebSearchLocation = { "city"?: string | null; "country"?: string | null; "region"?: string | null; "timezone"?: string | null };
|
||||
export type WebSearchMode = "disabled" | "cached" | "indexed" | "live";
|
||||
export type WebSearchToolConfig = { "allowed_domains"?: Array<string> | null; "context_size"?: WebSearchContextSize | null; "location"?: WebSearchLocation | null };
|
||||
export type WriteStatus = "ok" | "okOverridden";
|
||||
export type ClientRequestMap = {
|
||||
"initialize": { params: InitializeParams; result: InitializeResponse };
|
||||
"thread/start": { params: ThreadStartParams; result: ThreadStartResponse };
|
||||
"thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse };
|
||||
"thread/fork": { params: ThreadForkParams; result: ThreadForkResponse };
|
||||
"thread/archive": { params: ThreadArchiveParams; result: ThreadArchiveResponse };
|
||||
"thread/delete": { params: ThreadDeleteParams; result: ThreadDeleteResponse };
|
||||
"thread/unsubscribe": { params: ThreadUnsubscribeParams; result: ThreadUnsubscribeResponse };
|
||||
"thread/name/set": { params: ThreadSetNameParams; result: ThreadSetNameResponse };
|
||||
"thread/goal/set": { params: ThreadGoalSetParams; result: ThreadGoalSetResponse };
|
||||
"thread/goal/get": { params: ThreadGoalGetParams; result: ThreadGoalGetResponse };
|
||||
"thread/goal/clear": { params: ThreadGoalClearParams; result: ThreadGoalClearResponse };
|
||||
"thread/unarchive": { params: ThreadUnarchiveParams; result: ThreadUnarchiveResponse };
|
||||
"thread/compact/start": { params: ThreadCompactStartParams; result: ThreadCompactStartResponse };
|
||||
"thread/shellCommand": { params: ThreadShellCommandParams; result: ThreadShellCommandResponse };
|
||||
"thread/revert": { params: ThreadRevertParams; result: ThreadRevertResponse };
|
||||
"thread/list": { params: ThreadListParams; result: ThreadListResponse };
|
||||
"thread/loaded/list": { params: ThreadLoadedListParams; result: ThreadLoadedListResponse };
|
||||
"thread/read": { params: ThreadReadParams; result: ThreadReadResponse };
|
||||
"thread/turns/list": { params: ThreadTurnsListParams; result: ThreadTurnsListResponse };
|
||||
"thread/items/list": { params: ThreadItemsListParams; result: ThreadItemsListResponse };
|
||||
"skills/list": { params: SkillsListParams; result: SkillsListResponse };
|
||||
"hooks/list": { params: HooksListParams; result: HooksListResponse };
|
||||
"fs/readFile": { params: FsReadFileParams; result: FsReadFileResponse };
|
||||
"fs/writeFile": { params: FsWriteFileParams; result: FsWriteFileResponse };
|
||||
"fs/createDirectory": { params: FsCreateDirectoryParams; result: FsCreateDirectoryResponse };
|
||||
"fs/getMetadata": { params: FsGetMetadataParams; result: FsGetMetadataResponse };
|
||||
"fs/readDirectory": { params: FsReadDirectoryParams; result: FsReadDirectoryResponse };
|
||||
"fs/remove": { params: FsRemoveParams; result: FsRemoveResponse };
|
||||
"fs/copy": { params: FsCopyParams; result: FsCopyResponse };
|
||||
"fs/watch": { params: FsWatchParams; result: FsWatchResponse };
|
||||
"fs/unwatch": { params: FsUnwatchParams; result: FsUnwatchResponse };
|
||||
"turn/start": { params: TurnStartParams; result: TurnStartResponse };
|
||||
"turn/steer": { params: TurnSteerParams; result: TurnSteerResponse };
|
||||
"turn/interrupt": { params: TurnInterruptParams; result: TurnInterruptResponse };
|
||||
"review/start": { params: ReviewStartParams; result: ReviewStartResponse };
|
||||
"model/list": { params: ModelListParams; result: ModelListResponse };
|
||||
"mcpServerStatus/list": { params: ListMcpServerStatusParams; result: ListMcpServerStatusResponse };
|
||||
"command/exec": { params: CommandExecParams; result: CommandExecResponse };
|
||||
"command/exec/write": { params: CommandExecWriteParams; result: CommandExecWriteResponse };
|
||||
"command/exec/terminate": { params: CommandExecTerminateParams; result: CommandExecTerminateResponse };
|
||||
"command/exec/resize": { params: CommandExecResizeParams; result: CommandExecResizeResponse };
|
||||
"config/read": { params: ConfigReadParams; result: ConfigReadResponse };
|
||||
"config/value/write": { params: ConfigValueWriteParams; result: ConfigWriteResponse };
|
||||
"config/batchWrite": { params: ConfigBatchWriteParams; result: ConfigWriteResponse };
|
||||
};
|
||||
export type ClientNotificationMap = {
|
||||
"initialized": { params: undefined };
|
||||
};
|
||||
export type ServerRequestMap = {
|
||||
"item/commandExecution/requestApproval": { params: CommandExecutionRequestApprovalParams; result: CommandExecutionRequestApprovalResponse };
|
||||
"item/fileChange/requestApproval": { params: FileChangeRequestApprovalParams; result: FileChangeRequestApprovalResponse };
|
||||
"item/tool/requestUserInput": { params: ToolRequestUserInputParams; result: ToolRequestUserInputResponse };
|
||||
"item/permissions/requestApproval": { params: PermissionsRequestApprovalParams; result: PermissionsRequestApprovalResponse };
|
||||
"item/tool/call": { params: DynamicToolCallParams; result: DynamicToolCallResponse };
|
||||
};
|
||||
export type ServerNotificationMap = {
|
||||
"error": { params: ErrorNotification };
|
||||
"thread/started": { params: ThreadStartedNotification };
|
||||
"thread/status/changed": { params: ThreadStatusChangedNotification };
|
||||
"thread/archived": { params: ThreadArchivedNotification };
|
||||
"thread/deleted": { params: ThreadDeletedNotification };
|
||||
"thread/unarchived": { params: ThreadUnarchivedNotification };
|
||||
"thread/closed": { params: ThreadClosedNotification };
|
||||
"thread/reverted": { params: ThreadRevertedNotification };
|
||||
"skills/changed": { params: SkillsChangedNotification };
|
||||
"thread/name/updated": { params: ThreadNameUpdatedNotification };
|
||||
"thread/goal/updated": { params: ThreadGoalUpdatedNotification };
|
||||
"thread/goal/cleared": { params: ThreadGoalClearedNotification };
|
||||
"thread/tokenUsage/updated": { params: ThreadTokenUsageUpdatedNotification };
|
||||
"turn/started": { params: TurnStartedNotification };
|
||||
"turn/completed": { params: TurnCompletedNotification };
|
||||
"turn/diff/updated": { params: TurnDiffUpdatedNotification };
|
||||
"turn/plan/updated": { params: TurnPlanUpdatedNotification };
|
||||
"item/started": { params: ItemStartedNotification };
|
||||
"item/completed": { params: ItemCompletedNotification };
|
||||
"item/agentMessage/delta": { params: AgentMessageDeltaNotification };
|
||||
"item/plan/delta": { params: PlanDeltaNotification };
|
||||
"command/exec/outputDelta": { params: CommandExecOutputDeltaNotification };
|
||||
"item/commandExecution/outputDelta": { params: CommandExecutionOutputDeltaNotification };
|
||||
"item/commandExecution/terminalInteraction": { params: TerminalInteractionNotification };
|
||||
"item/fileChange/outputDelta": { params: FileChangeOutputDeltaNotification };
|
||||
"item/fileChange/patchUpdated": { params: FileChangePatchUpdatedNotification };
|
||||
"serverRequest/resolved": { params: ServerRequestResolvedNotification };
|
||||
"item/mcpToolCall/progress": { params: McpToolCallProgressNotification };
|
||||
"fs/changed": { params: FsChangedNotification };
|
||||
"item/reasoning/summaryTextDelta": { params: ReasoningSummaryTextDeltaNotification };
|
||||
"item/reasoning/summaryPartAdded": { params: ReasoningSummaryPartAddedNotification };
|
||||
"item/reasoning/textDelta": { params: ReasoningTextDeltaNotification };
|
||||
"thread/compacted": { params: ContextCompactedNotification };
|
||||
};
|
||||
@@ -1,152 +0,0 @@
|
||||
|
||||
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json | undefined }
|
||||
|
||||
export type DynamicToolCallContent = { type: 'input_text'; text: string } | { type: 'input_image'; image_url: string }
|
||||
|
||||
export type DynamicToolCallParams = {
|
||||
thread_id: string
|
||||
turn_id: string
|
||||
call_id: string
|
||||
namespace?: string
|
||||
tool: string
|
||||
arguments: Json
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type DynamicToolCallResult = {
|
||||
success: boolean
|
||||
content?: DynamicToolCallContent[]
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type DynamicToolItemStatus = 'in_progress' | 'completed' | 'failed'
|
||||
|
||||
export type DynamicToolSpec = {
|
||||
namespace?: (string | null)
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Json
|
||||
defer_loading?: boolean
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type DynamicToolSpecOutput = {
|
||||
namespace?: string
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Json
|
||||
defer_loading: boolean
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type FleetProfileDocument = {
|
||||
id: string
|
||||
kind: FleetProfileDocumentKind
|
||||
origin: string
|
||||
path: (string | null)
|
||||
text: string
|
||||
revision: string
|
||||
writable: boolean
|
||||
refusal_reason: (string | null)
|
||||
}
|
||||
|
||||
export type FleetProfileDocumentKind = 'profile' | 'fleet'
|
||||
|
||||
export type RuntimeCapabilities = {
|
||||
client_token_intents: boolean
|
||||
account_session: boolean
|
||||
account_model_owner: boolean
|
||||
session_provider_keys: boolean
|
||||
threads: boolean
|
||||
thread_create_idempotency: boolean
|
||||
thread_idle_precondition: boolean
|
||||
thread_patch_undo_turn_id: boolean
|
||||
thread_scoped_reads: boolean
|
||||
thread_scoped_git_writes: boolean
|
||||
thread_scoped_snapshots: boolean
|
||||
thread_scoped_terminals: boolean
|
||||
worktrees: boolean
|
||||
job_operations: boolean
|
||||
thread_shell_consent: boolean
|
||||
turns: boolean
|
||||
turn_operation_idempotency: boolean
|
||||
turn_operation_lookup: boolean
|
||||
watch_event_turns: boolean
|
||||
structured_review_turns: boolean
|
||||
structured_review_version: number
|
||||
automation_mutation_preconditions: boolean
|
||||
turn_image_inputs: boolean
|
||||
turn_output_token_limit: boolean
|
||||
profile_constitution: boolean
|
||||
turn_steer: boolean
|
||||
turn_interrupt: boolean
|
||||
event_replay: boolean
|
||||
external_tools: boolean
|
||||
environments: boolean
|
||||
worker_runtime: boolean
|
||||
fleet_run_create: boolean
|
||||
thread_fleet_members: boolean
|
||||
fleet_run_start: boolean
|
||||
fleet_event_replay: boolean
|
||||
fleet_event_stream: boolean
|
||||
fleet_local_target: boolean
|
||||
thread_goals: boolean
|
||||
memory: boolean
|
||||
mcp_server_management: boolean
|
||||
skill_lifecycle: boolean
|
||||
skill_detail: boolean
|
||||
plugin_management: boolean
|
||||
agent_mail: boolean
|
||||
event_stream_resume: boolean
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type RuntimeEventEnvelope = {
|
||||
schema_version: number
|
||||
seq: number
|
||||
event: string
|
||||
kind: string
|
||||
thread_id: string
|
||||
turn_id: (string | null)
|
||||
item_id: (string | null)
|
||||
timestamp: string
|
||||
created_at?: string
|
||||
payload: Json
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type RuntimeExperimentalCapabilities = {
|
||||
environments: boolean
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type RuntimeImageInput = {
|
||||
mime: string
|
||||
dataBase64: string
|
||||
}
|
||||
|
||||
export type ThreadLoadedListParams = {
|
||||
cursor?: (string | null)
|
||||
limit?: (number | null)
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type ThreadLoadedListResponse = {
|
||||
data: string[]
|
||||
nextCursor: (string | null)
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type ThreadSetNameParams = {
|
||||
threadId: string
|
||||
name: string
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
|
||||
export type ThreadSetNameResponse = Record<string, never>
|
||||
|
||||
export type TurnEnvironmentParams = {
|
||||
environment_id: string
|
||||
cwd: string
|
||||
[key: string]: Json | undefined
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
#[path = "../codegen/mod.rs"]
|
||||
mod codegen;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let arguments: Vec<String> = std::env::args().skip(1).collect();
|
||||
if !matches!(arguments.as_slice(), [mode] if mode == "--write" || mode == "--check") {
|
||||
return Err("use --write or --check against the contract manifest outputs".into());
|
||||
}
|
||||
let write = arguments[0] == "--write";
|
||||
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let manifest = std::fs::read_to_string(package.join(codegen::MANIFEST_PATH))?;
|
||||
let pinned = std::fs::read_to_string(package.join(codegen::PINNED_SCHEMA_PATH))?;
|
||||
let outputs = codegen::generate(&manifest, &pinned)?;
|
||||
let mut drift = Vec::new();
|
||||
for (path, text) in outputs.files {
|
||||
let target = package.join(path);
|
||||
if write {
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&target, text)?;
|
||||
} else if std::fs::read_to_string(&target).ok().as_deref() != Some(text.as_str()) {
|
||||
drift.push(path);
|
||||
}
|
||||
}
|
||||
if drift.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("generated contract drift: {}", drift.join(", ")).into())
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn retain(path: &Path, bytes: &[u8], write: bool) -> std::io::Result<()> {
|
||||
if write {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, bytes)
|
||||
} else if std::fs::read(path)? == bytes {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"generated contract drift: {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let arguments: Vec<String> = std::env::args().skip(1).collect();
|
||||
if !matches!(arguments.as_slice(), [mode] if mode == "--write" || mode == "--check") {
|
||||
return Err("use --write or --check against the declared package and SDK outputs".into());
|
||||
}
|
||||
let write = arguments[0] == "--write";
|
||||
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let repository = package
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.ok_or("package repository root")?;
|
||||
let schema = codewhale_app_server_protocol::export::runtime_schema();
|
||||
let typescript = codewhale_app_server_protocol::export::runtime_typescript(&schema);
|
||||
let json = serde_json::to_string_pretty(&schema)? + "\n";
|
||||
retain(
|
||||
&package.join("schema/json/runtime.schema.json"),
|
||||
json.as_bytes(),
|
||||
write,
|
||||
)?;
|
||||
retain(
|
||||
&package.join("schema/typescript/runtime.d.ts"),
|
||||
typescript.as_bytes(),
|
||||
write,
|
||||
)?;
|
||||
retain(
|
||||
&repository.join("npm/runtime-sdk/generated/runtime.d.ts"),
|
||||
typescript.as_bytes(),
|
||||
write,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,86 +0,0 @@
|
||||
use crate::rpc::{
|
||||
ThreadLoadedListParams, ThreadLoadedListResponse, ThreadSetNameParams, ThreadSetNameResponse,
|
||||
};
|
||||
use crate::runtime::*;
|
||||
use schemars::generate::SchemaSettings;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn output_references(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if object.get("$ref").and_then(Value::as_str) == Some("#/$defs/DynamicToolSpec") {
|
||||
object.insert(
|
||||
"$ref".into(),
|
||||
Value::String("#/$defs/DynamicToolSpecOutput".into()),
|
||||
);
|
||||
}
|
||||
for child in object.values_mut() {
|
||||
output_references(child);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
output_references(child);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_schema() -> Value {
|
||||
let mut input = SchemaSettings::draft2020_12()
|
||||
.for_deserialize()
|
||||
.into_generator();
|
||||
let _ = input.subschema_for::<RuntimeImageInput>();
|
||||
let _ = input.subschema_for::<ThreadLoadedListParams>();
|
||||
let _ = input.subschema_for::<ThreadSetNameParams>();
|
||||
let _ = input.subschema_for::<DynamicToolSpec>();
|
||||
let _ = input.subschema_for::<DynamicToolItemStatus>();
|
||||
let _ = input.subschema_for::<DynamicToolCallResult>();
|
||||
let _ = input.subschema_for::<DynamicToolCallContent>();
|
||||
let _ = input.subschema_for::<TurnEnvironmentParams>();
|
||||
let mut output = SchemaSettings::draft2020_12()
|
||||
.for_serialize()
|
||||
.into_generator();
|
||||
let _ = output.subschema_for::<RuntimeEventEnvelope>();
|
||||
let _ = output.subschema_for::<ThreadLoadedListResponse>();
|
||||
let _ = output.subschema_for::<ThreadSetNameResponse>();
|
||||
let _ = output.subschema_for::<RuntimeCapabilities>();
|
||||
let _ = output.subschema_for::<FleetProfileDocument>();
|
||||
let _ = output.subschema_for::<RuntimeExperimentalCapabilities>();
|
||||
let _ = output.subschema_for::<DynamicToolCallParams>();
|
||||
let _ = output.subschema_for::<DynamicToolSpec>();
|
||||
let _ = output.subschema_for::<TurnEnvironmentParams>();
|
||||
let _ = output.subschema_for::<DynamicToolItemStatus>();
|
||||
let mut definitions = input.take_definitions(false);
|
||||
for (name, mut schema) in output.take_definitions(false) {
|
||||
output_references(&mut schema);
|
||||
let name = if name == "DynamicToolSpec" {
|
||||
"DynamicToolSpecOutput".to_string()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
if let Some(previous) = definitions.get(&name) {
|
||||
assert_eq!(previous, &schema, "input/output schema conflict for {name}");
|
||||
} else {
|
||||
definitions.insert(name, schema);
|
||||
}
|
||||
}
|
||||
json!({"$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": definitions})
|
||||
}
|
||||
|
||||
pub fn runtime_typescript(schema: &Value) -> String {
|
||||
let definitions: BTreeMap<String, crate::schema::Def> = schema["$defs"]
|
||||
.as_object()
|
||||
.expect("Runtime schema definitions")
|
||||
.iter()
|
||||
.map(|(name, schema)| {
|
||||
(
|
||||
name.clone(),
|
||||
crate::schema::parse_def(name, schema, crate::schema::SchemaMode::Runtime),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
crate::schema::typescript_defs(&definitions, crate::schema::SchemaMode::Runtime)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
pub mod lifecycle;
|
||||
#[rustfmt::skip]
|
||||
pub mod methods;
|
||||
pub mod rpc;
|
||||
pub mod runtime;
|
||||
#[rustfmt::skip]
|
||||
pub mod v2;
|
||||
|
||||
#[cfg(feature = "schema")]
|
||||
pub mod schema;
|
||||
|
||||
#[cfg(feature = "schema")]
|
||||
pub mod export;
|
||||
@@ -1,45 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeParams {
|
||||
pub client_info: ClientInfo,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capabilities: Option<InitializeCapabilities>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientInfo {
|
||||
pub name: String,
|
||||
pub title: Option<String>,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeCapabilities {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub explicit_gateway_oauth: bool,
|
||||
#[serde(default)]
|
||||
pub experimental_api: bool,
|
||||
#[serde(default)]
|
||||
pub request_attestation: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub mcp_server_openai_form_elicitation: bool,
|
||||
pub opt_out_notification_methods: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extensions: Option<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeResponse {
|
||||
pub user_agent: String,
|
||||
pub codex_home: PathBuf,
|
||||
pub platform_family: String,
|
||||
pub platform_os: String,
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,312 +0,0 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub use crate::v2::{ThreadExecutor, ThreadPlacement as ThreadEnvironment};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
String(String),
|
||||
Integer(i64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct W3cTraceContext {
|
||||
pub traceparent: Option<String>,
|
||||
pub tracestate: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RpcRequest {
|
||||
pub id: RequestId,
|
||||
pub method: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trace: Option<W3cTraceContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RpcNotification {
|
||||
pub method: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RpcResponse {
|
||||
pub id: RequestId,
|
||||
pub result: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RpcErrorResponse {
|
||||
pub id: RequestId,
|
||||
pub error: RpcErrorDetails,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RpcErrorDetails {
|
||||
pub code: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RpcCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<RequestId>,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Value::is_null")]
|
||||
pub params: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace: Option<W3cTraceContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum RpcMessage {
|
||||
Request(RpcRequest),
|
||||
Notification(RpcNotification),
|
||||
Response(RpcResponse),
|
||||
Error(RpcErrorResponse),
|
||||
}
|
||||
|
||||
impl RpcMessage {
|
||||
pub fn into_call(self) -> Option<RpcCall> {
|
||||
match self {
|
||||
Self::Request(request) => Some(RpcCall {
|
||||
id: Some(request.id),
|
||||
method: request.method,
|
||||
params: request.params.unwrap_or(Value::Null),
|
||||
trace: request.trace,
|
||||
}),
|
||||
Self::Notification(notification) => Some(RpcCall {
|
||||
id: None,
|
||||
method: notification.method,
|
||||
params: notification.params.unwrap_or(Value::Null),
|
||||
trace: None,
|
||||
}),
|
||||
Self::Response(_) | Self::Error(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RpcCall {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
RpcMessage::deserialize(deserializer)?
|
||||
.into_call()
|
||||
.ok_or_else(|| serde::de::Error::custom("RPC response is not a call"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadLoadedListParams {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadLoadedListResponse {
|
||||
pub data: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadListResponse {
|
||||
pub data: Vec<ThreadListItem>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub backwards_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadListItem {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub preview: String,
|
||||
pub ephemeral: bool,
|
||||
pub model_provider: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub status: ThreadListStatus,
|
||||
pub cwd: String,
|
||||
pub cli_version: String,
|
||||
pub source: ThreadListSource,
|
||||
pub project_id: Option<String>,
|
||||
pub turns: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub executor: Option<ThreadExecutor>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub environment: Option<ThreadEnvironment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ThreadListStatus {
|
||||
NotLoaded,
|
||||
Idle,
|
||||
SystemError,
|
||||
Active {
|
||||
#[serde(rename = "activeFlags")]
|
||||
active_flags: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ThreadListSource {
|
||||
Cli,
|
||||
AppServer,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ThreadStartParams {
|
||||
pub operation_key: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub model_provider: Option<String>,
|
||||
pub cwd: Option<String>,
|
||||
pub ephemeral: Option<bool>,
|
||||
pub approval_policy: Option<Value>,
|
||||
pub approvals_reviewer: Option<Value>,
|
||||
pub sandbox: Option<Value>,
|
||||
pub config: Option<Value>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
pub service_name: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub session_start_source: Option<Value>,
|
||||
pub personality: Option<Value>,
|
||||
pub thread_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadStartResponse {
|
||||
pub thread: ThreadListItem,
|
||||
pub model: String,
|
||||
pub model_provider: String,
|
||||
pub cwd: String,
|
||||
pub approval_policy: String,
|
||||
pub approvals_reviewer: String,
|
||||
pub sandbox: ThreadSandboxPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ThreadSandboxPolicy {
|
||||
ReadOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ThreadReadParams {
|
||||
pub thread_id: String,
|
||||
#[serde(default)]
|
||||
pub include_turns: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadReadResponse {
|
||||
pub thread: ThreadListItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadTurn {
|
||||
pub id: String,
|
||||
pub items: Vec<ThreadTurnItem>,
|
||||
pub items_view: ThreadTurnItemsView,
|
||||
pub status: ThreadTurnStatus,
|
||||
pub error: Option<ThreadTurnError>,
|
||||
pub started_at: Option<i64>,
|
||||
pub completed_at: Option<i64>,
|
||||
pub duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ThreadTurnItemsView {
|
||||
Summary,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ThreadTurnStatus {
|
||||
Completed,
|
||||
Interrupted,
|
||||
Failed,
|
||||
InProgress,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadTurnError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ThreadTurnItem {
|
||||
UserMessage {
|
||||
id: String,
|
||||
content: Vec<ThreadUserInput>,
|
||||
},
|
||||
AgentMessage {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
Reasoning {
|
||||
id: String,
|
||||
content: Vec<String>,
|
||||
},
|
||||
ContextCompaction {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ThreadUserInput {
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadSetNameParams {
|
||||
pub thread_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ThreadSetNameResponse {}
|
||||
@@ -1,157 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MAX_WATCH_EVENT_RECEIPT_BYTES: usize = 4096;
|
||||
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct WatchEventReceipt {
|
||||
pub version: u32,
|
||||
pub platform_account_id: String,
|
||||
pub project_id: String,
|
||||
pub watch_id: String,
|
||||
pub watch_revision: u64,
|
||||
pub definition_sha256: String,
|
||||
pub source_kind: WatchEventSourceKind,
|
||||
pub occurrence_sha256: String,
|
||||
pub event_sha256: String,
|
||||
pub run_id: String,
|
||||
pub room_thread_root_id: String,
|
||||
pub source: WatchEventSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum WatchEventSourceKind {
|
||||
#[serde(rename = "schedule")]
|
||||
Schedule,
|
||||
#[serde(rename = "github.pull_request")]
|
||||
GitHubPullRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum WatchEventSource {
|
||||
Schedule(WatchScheduleSource),
|
||||
GitHubPullRequest(WatchPullRequestSource),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WatchScheduleSource {
|
||||
pub slot: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct WatchPullRequestSource {
|
||||
pub installation_id: String,
|
||||
pub binding_id: String,
|
||||
pub repo: String,
|
||||
pub number: u64,
|
||||
pub delivery_id: String,
|
||||
pub event_name: String,
|
||||
}
|
||||
|
||||
fn reference(value: &str) -> bool {
|
||||
(1..=200).contains(&value.len())
|
||||
&& value.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._:/@#-".contains(&byte))
|
||||
}
|
||||
|
||||
fn fingerprint(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
fn canonical_utc_slot(value: &str) -> bool {
|
||||
value.len() == 24
|
||||
&& chrono::DateTime::parse_from_rfc3339(value).is_ok_and(|parsed| {
|
||||
parsed.timestamp_subsec_nanos() < 1_000_000_000
|
||||
&& parsed.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) == value
|
||||
})
|
||||
}
|
||||
|
||||
impl WatchEventReceipt {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.version != 1 || self.watch_revision == 0 || self.watch_revision > MAX_SAFE_INTEGER {
|
||||
return Err("unsupported watch event version or revision");
|
||||
}
|
||||
if [
|
||||
&self.platform_account_id,
|
||||
&self.project_id,
|
||||
&self.watch_id,
|
||||
&self.run_id,
|
||||
]
|
||||
.iter()
|
||||
.any(|value| !reference(value))
|
||||
|| [
|
||||
&self.definition_sha256,
|
||||
&self.occurrence_sha256,
|
||||
&self.event_sha256,
|
||||
]
|
||||
.iter()
|
||||
.any(|value| !fingerprint(value))
|
||||
{
|
||||
return Err("invalid watch event reference or fingerprint");
|
||||
}
|
||||
if !self.room_thread_root_id.is_empty()
|
||||
&& (self.room_thread_root_id.len() != 35
|
||||
|| !self.room_thread_root_id.starts_with("pm_")
|
||||
|| !self.room_thread_root_id.as_bytes()[3..]
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)))
|
||||
{
|
||||
return Err("invalid watch event room thread");
|
||||
}
|
||||
match (&self.source_kind, &self.source) {
|
||||
(WatchEventSourceKind::Schedule, WatchEventSource::Schedule(source)) => {
|
||||
if source.slot.len() != 24
|
||||
|| !reference(&source.slot)
|
||||
|| !canonical_utc_slot(&source.slot)
|
||||
{
|
||||
return Err("invalid watch event schedule occurrence");
|
||||
}
|
||||
}
|
||||
(
|
||||
WatchEventSourceKind::GitHubPullRequest,
|
||||
WatchEventSource::GitHubPullRequest(source),
|
||||
) => {
|
||||
let valid_repo = source.repo.split_once('/').is_some_and(|(owner, name)| {
|
||||
[owner, name].iter().all(|part| {
|
||||
(1..=100).contains(&part.len())
|
||||
&& part
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(&byte))
|
||||
})
|
||||
});
|
||||
if source.number == 0
|
||||
|| source.number > MAX_SAFE_INTEGER
|
||||
|| [
|
||||
&source.installation_id,
|
||||
&source.binding_id,
|
||||
&source.delivery_id,
|
||||
&source.event_name,
|
||||
]
|
||||
.iter()
|
||||
.any(|value| !reference(value))
|
||||
|| !valid_repo
|
||||
{
|
||||
return Err("invalid watch event pull request occurrence");
|
||||
}
|
||||
}
|
||||
_ => return Err("watch event kind does not match its source"),
|
||||
}
|
||||
if serde_json::to_vec(self)
|
||||
.map_err(|_| "watch event cannot be encoded")?
|
||||
.len()
|
||||
> MAX_WATCH_EVENT_RECEIPT_BYTES
|
||||
{
|
||||
return Err("watch event receipt exceeds 4 KiB");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SchemaMode {
|
||||
Host,
|
||||
Runtime,
|
||||
}
|
||||
|
||||
/// A field's wire type, normalized.
|
||||
pub enum Ty {
|
||||
Nullable(Box<Ty>),
|
||||
String,
|
||||
Boolean,
|
||||
Uint,
|
||||
Integer,
|
||||
/// A JSON object with any keys (`Map<String, Value>`).
|
||||
Object,
|
||||
/// Any JSON value.
|
||||
Json,
|
||||
Ref(String),
|
||||
/// A string tag inside a union member.
|
||||
Const(String),
|
||||
Array(Box<Ty>),
|
||||
}
|
||||
|
||||
pub struct Field {
|
||||
pub name: String,
|
||||
pub ty: Ty,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub enum Def {
|
||||
Object {
|
||||
strict: bool,
|
||||
extra: bool,
|
||||
fields: Vec<Field>,
|
||||
},
|
||||
Enum(Vec<String>),
|
||||
Union(Vec<Vec<Field>>),
|
||||
}
|
||||
|
||||
pub fn ref_name(schema: &Value) -> Option<String> {
|
||||
let reference = schema.get("$ref")?.as_str()?;
|
||||
Some(
|
||||
reference
|
||||
.strip_prefix("#/$defs/")
|
||||
.unwrap_or_else(|| panic!("unexpected $ref `{reference}`"))
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_ty(at: &str, schema: &Value, mode: SchemaMode) -> Ty {
|
||||
let Some(object) = schema.as_object() else {
|
||||
assert_eq!(schema, &Value::Bool(true), "{at}: unsupported schema");
|
||||
return Ty::Json;
|
||||
};
|
||||
if let Some(name) = ref_name(schema) {
|
||||
return Ty::Ref(name);
|
||||
}
|
||||
if let Some(variants) = object.get("anyOf").and_then(Value::as_array)
|
||||
&& variants.len() == 2
|
||||
&& variants
|
||||
.iter()
|
||||
.filter(|variant| variant.get("type").and_then(Value::as_str) == Some("null"))
|
||||
.count()
|
||||
== 1
|
||||
{
|
||||
let present = parse_ty(
|
||||
at,
|
||||
variants
|
||||
.iter()
|
||||
.find(|variant| variant.get("type").and_then(Value::as_str) != Some("null"))
|
||||
.expect("one non-null variant"),
|
||||
mode,
|
||||
);
|
||||
return if mode == SchemaMode::Runtime {
|
||||
Ty::Nullable(Box::new(present))
|
||||
} else {
|
||||
present
|
||||
};
|
||||
}
|
||||
if let Some(value) = object.get("const").and_then(Value::as_str) {
|
||||
return Ty::Const(value.to_string());
|
||||
}
|
||||
let types: Vec<&str> = match object.get("type") {
|
||||
Some(Value::String(ty)) => vec![ty.as_str()],
|
||||
Some(Value::Array(types)) => types
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|ty| *ty != "null")
|
||||
.collect(),
|
||||
// Only annotations (`default`, `description`): any value.
|
||||
None if !["enum", "anyOf", "oneOf", "allOf", "properties", "items"]
|
||||
.iter()
|
||||
.any(|key| object.contains_key(*key)) =>
|
||||
{
|
||||
return Ty::Json;
|
||||
}
|
||||
_ => panic!("{at}: unsupported wire schema {schema}"),
|
||||
};
|
||||
let value = match types.as_slice() {
|
||||
["string"] => Ty::String,
|
||||
["boolean"] => Ty::Boolean,
|
||||
["integer"] if object.get("minimum").and_then(Value::as_u64) == Some(0) => Ty::Uint,
|
||||
["integer"] => Ty::Integer,
|
||||
["object"]
|
||||
if object.get("additionalProperties") == Some(&Value::Bool(true))
|
||||
&& !object.contains_key("properties") =>
|
||||
{
|
||||
Ty::Object
|
||||
}
|
||||
["array"] => Ty::Array(Box::new(parse_ty(
|
||||
&format!("{at}[]"),
|
||||
object
|
||||
.get("items")
|
||||
.unwrap_or_else(|| panic!("{at}: array without items")),
|
||||
mode,
|
||||
))),
|
||||
_ => panic!("{at}: unsupported wire schema {schema}"),
|
||||
};
|
||||
if mode == SchemaMode::Runtime
|
||||
&& object
|
||||
.get("type")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|types| types.iter().any(|ty| ty == "null"))
|
||||
{
|
||||
Ty::Nullable(Box::new(value))
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn object_fields(at: &str, schema: &Value, mode: SchemaMode) -> Vec<Field> {
|
||||
assert_eq!(
|
||||
schema.get("type").and_then(Value::as_str),
|
||||
Some("object"),
|
||||
"{at}: expected an object schema, got {schema}"
|
||||
);
|
||||
let required: Vec<&str> = schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.map(|names| names.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.map(|properties| {
|
||||
properties
|
||||
.iter()
|
||||
.map(|(name, schema)| Field {
|
||||
name: name.clone(),
|
||||
ty: parse_ty(&format!("{at}.{name}"), schema, mode),
|
||||
required: required.contains(&name.as_str()),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn parse_def(name: &str, schema: &Value, mode: SchemaMode) -> Def {
|
||||
let members = schema
|
||||
.get("oneOf")
|
||||
.or_else(|| schema.get("anyOf"))
|
||||
.and_then(Value::as_array);
|
||||
if let Some(members) = members {
|
||||
// Unit variants, with or without doc comments: a string enum.
|
||||
let consts: Option<Vec<String>> = members
|
||||
.iter()
|
||||
.map(|member| {
|
||||
member
|
||||
.get("const")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect();
|
||||
return match consts {
|
||||
Some(values) => Def::Enum(values),
|
||||
None if members.len() == 1 => Def::Object {
|
||||
strict: members[0].get("additionalProperties") == Some(&Value::Bool(false)),
|
||||
extra: mode == SchemaMode::Runtime
|
||||
&& members[0].get("additionalProperties") != Some(&Value::Bool(false)),
|
||||
fields: object_fields(name, &members[0], mode),
|
||||
},
|
||||
None => Def::Union(
|
||||
members
|
||||
.iter()
|
||||
.map(|member| object_fields(name, member, mode))
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
}
|
||||
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
|
||||
return Def::Enum(
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{name}: non-string enum value"))
|
||||
.to_string()
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Def::Object {
|
||||
strict: schema.get("additionalProperties") == Some(&Value::Bool(false)),
|
||||
extra: mode == SchemaMode::Runtime
|
||||
&& schema.get("additionalProperties") != Some(&Value::Bool(false)),
|
||||
fields: object_fields(name, schema, mode),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote(value: &str) -> String {
|
||||
assert!(
|
||||
!value.contains(['\'', '\\']),
|
||||
"`{value}` needs escaping in TypeScript"
|
||||
);
|
||||
format!("'{value}'")
|
||||
}
|
||||
|
||||
fn ts(ty: &Ty) -> String {
|
||||
match ty {
|
||||
Ty::Nullable(inner) => format!("({} | null)", ts(inner)),
|
||||
Ty::String => "string".into(),
|
||||
Ty::Boolean => "boolean".into(),
|
||||
Ty::Uint | Ty::Integer => "number".into(),
|
||||
Ty::Object => "{ [key: string]: Json }".into(),
|
||||
Ty::Json => "Json".into(),
|
||||
Ty::Ref(name) => name.clone(),
|
||||
Ty::Const(value) => quote(value),
|
||||
Ty::Array(item) => format!("{}[]", ts(item)),
|
||||
}
|
||||
}
|
||||
|
||||
/// An array's item type, however deeply nested.
|
||||
pub fn innermost(ty: &Ty) -> &Ty {
|
||||
match ty {
|
||||
Ty::Array(item) => innermost(item),
|
||||
ty => ty,
|
||||
}
|
||||
}
|
||||
|
||||
fn member(field: &Field) -> String {
|
||||
let optional = if field.required { "" } else { "?" };
|
||||
format!("{}{optional}: {}", field.name, ts(&field.ty))
|
||||
}
|
||||
|
||||
pub fn typescript_defs(defs: &BTreeMap<String, Def>, mode: SchemaMode) -> String {
|
||||
let mut out = String::from(if mode == SchemaMode::Host {
|
||||
"\nexport type Json = null | boolean | number | string | Json[] | { [key: string]: Json }\n"
|
||||
} else {
|
||||
"\nexport type Json = null | boolean | number | string | Json[] | { [key: string]: Json | undefined }\n"
|
||||
});
|
||||
for (name, def) in defs {
|
||||
out.push('\n');
|
||||
match def {
|
||||
Def::Object { fields, extra, .. } if fields.is_empty() && !extra => {
|
||||
if mode == SchemaMode::Host {
|
||||
let _ = writeln!(out, "export interface {name} {{}}");
|
||||
} else {
|
||||
let _ = writeln!(out, "export type {name} = Record<string, never>");
|
||||
}
|
||||
}
|
||||
Def::Object { fields, extra, .. } => {
|
||||
let declaration = if mode == SchemaMode::Host {
|
||||
format!("export interface {name}")
|
||||
} else {
|
||||
format!("export type {name} =")
|
||||
};
|
||||
let _ = writeln!(out, "{declaration} {{");
|
||||
for field in fields {
|
||||
let _ = writeln!(out, " {}", member(field));
|
||||
}
|
||||
if *extra {
|
||||
out.push_str(" [key: string]: Json | undefined\n");
|
||||
}
|
||||
out.push_str("}\n");
|
||||
}
|
||||
Def::Enum(values) => {
|
||||
let values: Vec<String> = values.iter().map(|v| quote(v)).collect();
|
||||
let _ = writeln!(out, "export type {name} = {}", values.join(" | "));
|
||||
}
|
||||
Def::Union(members) => {
|
||||
let members: Vec<String> = members
|
||||
.iter()
|
||||
.map(|fields| {
|
||||
// Tags first, then the variant's fields in order.
|
||||
let (tags, rest): (Vec<&Field>, Vec<&Field>) = fields
|
||||
.iter()
|
||||
.partition(|field| matches!(field.ty, Ty::Const(_)));
|
||||
let parts: Vec<String> = tags.into_iter().chain(rest).map(member).collect();
|
||||
format!("{{ {} }}", parts.join("; "))
|
||||
})
|
||||
.collect();
|
||||
let _ = writeln!(out, "export type {name} = {}", members.join(" | "));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,633 +0,0 @@
|
||||
#[path = "../src/codegen/mod.rs"]
|
||||
mod codegen;
|
||||
|
||||
use codewhale_app_server_protocol::methods::{
|
||||
self, ClientNotificationMethod, ClientRequestMethod, Disposition, ServerNotificationMethod,
|
||||
ServerRequestMethod, Tier, error_code,
|
||||
};
|
||||
use codewhale_app_server_protocol::rpc::RpcCall;
|
||||
use codewhale_app_server_protocol::v2;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const MANIFEST: &str = include_str!("../contract/codex-v2.json");
|
||||
const PINNED: &str = include_str!("pinned/codex/codex_app_server_protocol.schemas.json");
|
||||
const COMMON: &str = include_str!("pinned/codex/common.rs");
|
||||
|
||||
fn manifest() -> Value {
|
||||
serde_json::from_str(MANIFEST).unwrap()
|
||||
}
|
||||
|
||||
fn pinned() -> Value {
|
||||
serde_json::from_str(PINNED).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_outputs_have_not_drifted_from_the_manifest_and_pinned_export() {
|
||||
let package = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let read = |path: &str| std::fs::read_to_string(package.join(path)).unwrap();
|
||||
assert_eq!(read(codegen::MANIFEST_PATH), MANIFEST);
|
||||
assert_eq!(read(codegen::PINNED_SCHEMA_PATH), PINNED);
|
||||
let outputs = codegen::generate(MANIFEST, PINNED).unwrap();
|
||||
let committed: BTreeMap<&str, &str> = BTreeMap::from([
|
||||
("src/methods.rs", include_str!("../src/methods.rs")),
|
||||
("src/v2.rs", include_str!("../src/v2.rs")),
|
||||
("METHODS.md", include_str!("../METHODS.md")),
|
||||
(
|
||||
"schema/json/codex-v2.schema.json",
|
||||
include_str!("../schema/json/codex-v2.schema.json"),
|
||||
),
|
||||
(
|
||||
"schema/typescript/codex-v2.d.ts",
|
||||
include_str!("../schema/typescript/codex-v2.d.ts"),
|
||||
),
|
||||
]);
|
||||
assert_eq!(outputs.files.len(), committed.len());
|
||||
for (path, text) in outputs.files {
|
||||
assert!(
|
||||
committed[path] == text,
|
||||
"{path} drifted; run cargo run -p codewhale-app-server-protocol --bin codewhale-codex-contract -- --write"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const KINDS: [(&str, &str, &str); 4] = [
|
||||
(
|
||||
"clientRequests",
|
||||
include_str!("pinned/codex/ClientRequest.ts"),
|
||||
"ClientRequest",
|
||||
),
|
||||
(
|
||||
"clientNotifications",
|
||||
include_str!("pinned/codex/ClientNotification.ts"),
|
||||
"ClientNotification",
|
||||
),
|
||||
(
|
||||
"serverRequests",
|
||||
include_str!("pinned/codex/ServerRequest.ts"),
|
||||
"ServerRequest",
|
||||
),
|
||||
(
|
||||
"serverNotifications",
|
||||
include_str!("pinned/codex/ServerNotification.ts"),
|
||||
"ServerNotification",
|
||||
),
|
||||
];
|
||||
|
||||
fn pinned_ts_methods(source: &str) -> Vec<&str> {
|
||||
source
|
||||
.split("\"method\": \"")
|
||||
.skip(1)
|
||||
.map(|rest| rest.split('"').next().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn typed(kind: &str) -> Vec<(&'static str, Tier)> {
|
||||
match kind {
|
||||
"clientRequests" => ClientRequestMethod::ALL
|
||||
.iter()
|
||||
.map(|method| (method.method(), method.tier()))
|
||||
.collect(),
|
||||
"clientNotifications" => ClientNotificationMethod::ALL
|
||||
.iter()
|
||||
.map(|method| (method.method(), method.tier()))
|
||||
.collect(),
|
||||
"serverRequests" => ServerRequestMethod::ALL
|
||||
.iter()
|
||||
.map(|method| (method.method(), method.tier()))
|
||||
.collect(),
|
||||
_ => ServerNotificationMethod::ALL
|
||||
.iter()
|
||||
.map(|method| (method.method(), method.tier()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn disposition(kind: &str, method: &str) -> Option<Disposition> {
|
||||
match kind {
|
||||
"clientRequests" => methods::client_request_disposition(method),
|
||||
"clientNotifications" => methods::client_notification_disposition(method),
|
||||
"serverRequests" => methods::server_request_disposition(method),
|
||||
_ => methods::server_notification_disposition(method),
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_response(method: &str) -> String {
|
||||
let marker = format!("=> \"{method}\" {{");
|
||||
let start = COMMON
|
||||
.find(&marker)
|
||||
.unwrap_or_else(|| panic!("pinned common.rs has no {method}"));
|
||||
let rest = &COMMON[start..];
|
||||
let end = rest.find("\n },").unwrap();
|
||||
let body = &rest[..end];
|
||||
let response = body
|
||||
.split("response: ")
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| panic!("{method} has no response in common.rs"));
|
||||
let ty = response.split(',').next().unwrap().trim();
|
||||
ty.rsplit("::").next().unwrap().to_owned()
|
||||
}
|
||||
|
||||
fn pinned_union_params(pinned: &Value, union: &str, method: &str) -> Option<String> {
|
||||
pinned["definitions"][union]["oneOf"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|variant| variant["properties"]["method"]["enum"][0] == method)
|
||||
.unwrap_or_else(|| panic!("pinned {union} has no {method}"))["properties"]["params"]["$ref"]
|
||||
.as_str()
|
||||
.map(|reference| reference.rsplit('/').next().unwrap().to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_inventory_matches_the_pinned_method_sets_and_typed_enums() {
|
||||
let manifest = manifest();
|
||||
let pinned = pinned();
|
||||
let mut totals: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for (kind, ts, union) in KINDS {
|
||||
let pinned_methods = pinned_ts_methods(ts);
|
||||
let pinned_set: BTreeSet<&str> = pinned_methods.iter().copied().collect();
|
||||
assert_eq!(pinned_methods.len(), pinned_set.len(), "{kind}: duplicate");
|
||||
let rows = manifest["methods"][kind].as_array().unwrap();
|
||||
let mut seen = BTreeSet::new();
|
||||
for row in rows {
|
||||
let method = row["method"].as_str().unwrap();
|
||||
let tier = row["tier"].as_str().unwrap();
|
||||
let status = row["status"].as_str().unwrap();
|
||||
assert!(seen.insert(method), "{kind}: {method} twice");
|
||||
assert!(pinned_set.contains(method), "{kind}: {method} not pinned");
|
||||
assert!(matches!(tier, "A" | "B" | "C" | "undecided"));
|
||||
assert!(matches!(
|
||||
status,
|
||||
"implemented" | "partial" | "missing" | "method-not-found" | "never-sent"
|
||||
));
|
||||
assert_eq!(
|
||||
tier == "C",
|
||||
matches!(status, "method-not-found" | "never-sent"),
|
||||
"{kind}: {method} status {status} does not fit tier {tier}"
|
||||
);
|
||||
assert_eq!(
|
||||
row.get("recommended").is_some(),
|
||||
tier == "undecided",
|
||||
"{kind}: {method} recommendation belongs to undecided rows only"
|
||||
);
|
||||
let expected = match tier {
|
||||
"A" => Disposition::Selected(Tier::A),
|
||||
"B" => Disposition::Selected(Tier::B),
|
||||
"C" => Disposition::C,
|
||||
_ => Disposition::Undecided,
|
||||
};
|
||||
assert_eq!(
|
||||
disposition(kind, method),
|
||||
Some(expected),
|
||||
"{kind}: {method}"
|
||||
);
|
||||
*totals.entry(format!("{kind}/{tier}")).or_default() += 1;
|
||||
if matches!(tier, "A" | "B") {
|
||||
let params = pinned_union_params(&pinned, union, method);
|
||||
assert_eq!(
|
||||
row["params"].as_str().map(str::to_owned),
|
||||
params,
|
||||
"{kind}: {method} params disagree with the pinned {union} union"
|
||||
);
|
||||
if matches!(kind, "clientRequests" | "serverRequests") {
|
||||
assert_eq!(
|
||||
row["response"].as_str().unwrap(),
|
||||
upstream_response(method),
|
||||
"{kind}: {method} result disagrees with pinned common.rs"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
seen, pinned_set,
|
||||
"{kind}: manifest and pinned export differ"
|
||||
);
|
||||
let typed_set: BTreeSet<&str> = typed(kind).iter().map(|(method, _)| *method).collect();
|
||||
let selected: BTreeSet<&str> = rows
|
||||
.iter()
|
||||
.filter(|row| matches!(row["tier"].as_str(), Some("A" | "B")))
|
||||
.map(|row| row["method"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
typed_set, selected,
|
||||
"{kind}: typed enum and selected rows differ"
|
||||
);
|
||||
}
|
||||
eprintln!("inventory totals: {totals:?}");
|
||||
assert_eq!(totals["clientRequests/A"] + totals["clientRequests/B"], 44);
|
||||
assert_eq!(totals["serverRequests/A"], 5);
|
||||
assert_eq!(
|
||||
totals["serverNotifications/A"] + totals["serverNotifications/B"],
|
||||
33
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_methods_round_trip_their_wire_names() {
|
||||
for method in ClientRequestMethod::ALL {
|
||||
assert_eq!(
|
||||
ClientRequestMethod::from_method(method.method()),
|
||||
Some(*method)
|
||||
);
|
||||
let wire = serde_json::to_value(method).unwrap();
|
||||
assert_eq!(wire, method.method());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ClientRequestMethod>(wire).unwrap(),
|
||||
*method
|
||||
);
|
||||
}
|
||||
for method in ServerNotificationMethod::ALL {
|
||||
assert_eq!(
|
||||
ServerNotificationMethod::from_method(method.method()),
|
||||
Some(*method)
|
||||
);
|
||||
}
|
||||
for method in ServerRequestMethod::ALL {
|
||||
assert_eq!(
|
||||
ServerRequestMethod::from_method(method.method()),
|
||||
Some(*method)
|
||||
);
|
||||
}
|
||||
assert!(serde_json::from_value::<ClientRequestMethod>("feedback/upload".into()).is_err());
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Mode {
|
||||
Full,
|
||||
Minimal,
|
||||
}
|
||||
|
||||
struct Instances {
|
||||
definitions: Value,
|
||||
}
|
||||
|
||||
const DEPTH: usize = 5;
|
||||
|
||||
impl Instances {
|
||||
fn new() -> Self {
|
||||
let mut definitions = pinned()["definitions"].clone();
|
||||
definitions["codewhale"] = manifest()["extensions"]["definitions"].clone();
|
||||
Instances { definitions }
|
||||
}
|
||||
|
||||
fn resolve(&self, reference: &str) -> &Value {
|
||||
let mut node = &self.definitions;
|
||||
for part in reference.trim_start_matches("#/definitions/").split('/') {
|
||||
node = &node[part];
|
||||
}
|
||||
assert!(!node.is_null(), "unresolved {reference}");
|
||||
node
|
||||
}
|
||||
|
||||
fn lookup(&self, name: &str) -> Option<(String, &Value)> {
|
||||
for namespace in ["v2/", "", "codewhale/"] {
|
||||
let reference = format!("#/definitions/{namespace}{name}");
|
||||
let mut node = &self.definitions;
|
||||
for part in reference.trim_start_matches("#/definitions/").split('/') {
|
||||
node = &node[part];
|
||||
}
|
||||
if !node.is_null() && !(namespace.is_empty() && name == "v2") {
|
||||
return Some((reference, node));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn validator(&self, reference: &str) -> jsonschema::Validator {
|
||||
let schema = json!({
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": self.definitions,
|
||||
"allOf": [{"$ref": reference}],
|
||||
});
|
||||
jsonschema::validator_for(&schema).unwrap()
|
||||
}
|
||||
|
||||
fn members<'a>(&self, schema: &'a Value) -> Option<&'a Vec<Value>> {
|
||||
schema["oneOf"].as_array().or(schema["anyOf"].as_array())
|
||||
}
|
||||
|
||||
fn value(&self, schema: &Value, mode: Mode, depth: usize) -> Value {
|
||||
if schema == &Value::Bool(true) {
|
||||
return json!({"any": [1, "x"]});
|
||||
}
|
||||
if let Some(reference) = schema["$ref"].as_str() {
|
||||
return self.value(self.resolve(reference), mode, depth + 1);
|
||||
}
|
||||
if let Some(all) = schema["allOf"].as_array() {
|
||||
return self.value(&all[0], mode, depth);
|
||||
}
|
||||
if let Some(members) = self.members(schema) {
|
||||
let member = members
|
||||
.iter()
|
||||
.find(|member| member["type"] != "null")
|
||||
.unwrap();
|
||||
return self.member_value(schema, member, mode, depth);
|
||||
}
|
||||
if let Some(values) = schema["enum"].as_array() {
|
||||
return values[0].clone();
|
||||
}
|
||||
let ty = match &schema["type"] {
|
||||
Value::String(ty) => ty.clone(),
|
||||
Value::Array(list) => list
|
||||
.iter()
|
||||
.find(|ty| *ty != "null")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap()
|
||||
.to_owned(),
|
||||
_ => return json!({"any": [1, "x"]}),
|
||||
};
|
||||
match ty.as_str() {
|
||||
"string" => json!("s"),
|
||||
"integer" => json!(1),
|
||||
"boolean" => json!(true),
|
||||
"array" => {
|
||||
if mode == Mode::Full && depth < DEPTH {
|
||||
json!([self.value(&schema["items"], mode, depth + 1)])
|
||||
} else {
|
||||
json!([])
|
||||
}
|
||||
}
|
||||
"object" => self.object(schema, mode, depth),
|
||||
other => panic!("unsupported type {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn object(&self, schema: &Value, mode: Mode, depth: usize) -> Value {
|
||||
let mut out = Map::new();
|
||||
let required: BTreeSet<&str> = schema["required"]
|
||||
.as_array()
|
||||
.map(|names| names.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
let full = mode == Mode::Full && depth < DEPTH;
|
||||
if let Some(properties) = schema["properties"].as_object() {
|
||||
for (name, property) in properties {
|
||||
if required.contains(name.as_str()) || full {
|
||||
out.insert(name.clone(), self.value(property, mode, depth + 1));
|
||||
} else if let Some(default) =
|
||||
property.get("default").filter(|value| !value.is_null())
|
||||
{
|
||||
out.insert(name.clone(), default.clone());
|
||||
}
|
||||
}
|
||||
if full && schema["additionalProperties"] == true {
|
||||
out.insert("x-additional".into(), json!(1));
|
||||
}
|
||||
} else if full {
|
||||
match &schema["additionalProperties"] {
|
||||
Value::Object(_) => {
|
||||
out.insert(
|
||||
"k".into(),
|
||||
self.value(&schema["additionalProperties"], mode, depth + 1),
|
||||
);
|
||||
}
|
||||
Value::Bool(true) => {
|
||||
out.insert("k".into(), json!(1));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
fn member_value(&self, schema: &Value, member: &Value, mode: Mode, depth: usize) -> Value {
|
||||
let mut value = self.value(member, mode, depth);
|
||||
if schema.get("properties").is_some()
|
||||
&& let (Value::Object(base), Value::Object(extra)) =
|
||||
(self.object(schema, mode, depth), &mut value)
|
||||
{
|
||||
for (key, field) in base {
|
||||
extra.entry(key).or_insert(field);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn cases(&self, schema: &Value) -> Vec<(String, Value)> {
|
||||
let mut cases = vec![
|
||||
("full".to_owned(), self.value(schema, Mode::Full, 0)),
|
||||
("minimal".to_owned(), self.value(schema, Mode::Minimal, 0)),
|
||||
];
|
||||
if let Some(members) = self.members(schema) {
|
||||
for (index, member) in members.iter().enumerate() {
|
||||
if member["type"] == "null" {
|
||||
continue;
|
||||
}
|
||||
if let Some(values) = member["enum"].as_array() {
|
||||
for value in values {
|
||||
cases.push((format!("member {index} {value}"), value.clone()));
|
||||
}
|
||||
} else {
|
||||
cases.push((
|
||||
format!("member {index} full"),
|
||||
self.member_value(schema, member, Mode::Full, 0),
|
||||
));
|
||||
cases.push((
|
||||
format!("member {index} minimal"),
|
||||
self.member_value(schema, member, Mode::Minimal, 0),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(values) = schema["enum"].as_array() {
|
||||
for value in values {
|
||||
cases.push((format!("enum {value}"), value.clone()));
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_selected_shape_round_trips_against_the_pinned_schema() {
|
||||
let instances = Instances::new();
|
||||
let mut checked = 0usize;
|
||||
let mut typed = 0usize;
|
||||
let mut inline = Vec::new();
|
||||
for name in v2::TYPE_NAMES {
|
||||
let Some((reference, schema)) = instances.lookup(name) else {
|
||||
inline.push(*name);
|
||||
continue;
|
||||
};
|
||||
typed += 1;
|
||||
let validator = instances.validator(&reference);
|
||||
for (case, value) in instances.cases(schema) {
|
||||
assert!(
|
||||
validator.is_valid(&value),
|
||||
"{name} {case}: generated instance is not valid upstream: {value}"
|
||||
);
|
||||
let again = v2::round_trip(name, value.clone())
|
||||
.unwrap()
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("{name} {case}: typed decode failed: {error}: {value}")
|
||||
});
|
||||
assert!(
|
||||
validator.is_valid(&again),
|
||||
"{name} {case}: typed output is not valid upstream: {again}"
|
||||
);
|
||||
assert_eq!(
|
||||
again, value,
|
||||
"{name} {case}: typed round trip changed the value"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"typed definitions with a pinned schema: {typed}; inline helper types: {}; round-tripped instances: {checked}",
|
||||
inline.len()
|
||||
);
|
||||
assert!(typed > 250 && checked > typed * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_envelopes_match_the_pinned_unions_without_jsonrpc() {
|
||||
let instances = Instances::new();
|
||||
let client_request = instances.validator("#/definitions/ClientRequest");
|
||||
let server_request = instances.validator("#/definitions/ServerRequest");
|
||||
let server_notification = instances.validator("#/definitions/ServerNotification");
|
||||
let client_notification = instances.validator("#/definitions/ClientNotification");
|
||||
let mut counts = BTreeMap::new();
|
||||
for method in ClientRequestMethod::ALL {
|
||||
let (_, params_schema) = instances.lookup(method.params_type().unwrap()).unwrap();
|
||||
let (_, result_schema) = instances.lookup(method.response_type()).unwrap();
|
||||
for mode in [Mode::Full, Mode::Minimal] {
|
||||
let params = instances.value(params_schema, mode, 0);
|
||||
let envelope = json!({"id": 7, "method": method.method(), "params": params});
|
||||
assert!(client_request.is_valid(&envelope), "{method}: {envelope}");
|
||||
let call: RpcCall = serde_json::from_value(envelope).unwrap();
|
||||
let typed: v2::ClientRequestParams =
|
||||
serde_json::from_value(json!({"method": call.method, "params": call.params}))
|
||||
.unwrap_or_else(|error| panic!("{method}: {error}"));
|
||||
assert_eq!(typed.method(), *method);
|
||||
let encoded = serde_json::to_value(&typed).unwrap();
|
||||
assert!(encoded.get("jsonrpc").is_none());
|
||||
assert_eq!(encoded["params"], params);
|
||||
let result = instances.value(result_schema, mode, 0);
|
||||
let response = v2::ClientResponse::decode(*method, result.clone()).unwrap();
|
||||
assert_eq!(response.method(), *method);
|
||||
assert_eq!(serde_json::to_value(&response).unwrap(), result);
|
||||
}
|
||||
*counts.entry("client requests").or_insert(0) += 1;
|
||||
}
|
||||
for method in ServerRequestMethod::ALL {
|
||||
let (_, params_schema) = instances.lookup(method.params_type().unwrap()).unwrap();
|
||||
let (_, result_schema) = instances.lookup(method.response_type()).unwrap();
|
||||
for mode in [Mode::Full, Mode::Minimal] {
|
||||
let params = instances.value(params_schema, mode, 0);
|
||||
let typed: v2::ServerRequestParams =
|
||||
serde_json::from_value(json!({"method": method.method(), "params": params}))
|
||||
.unwrap();
|
||||
let mut encoded = serde_json::to_value(&typed).unwrap();
|
||||
encoded["id"] = json!("server-1");
|
||||
assert!(server_request.is_valid(&encoded), "{method}: {encoded}");
|
||||
let result = instances.value(result_schema, mode, 0);
|
||||
let answer = v2::ServerRequestResponse::decode(*method, result.clone()).unwrap();
|
||||
assert_eq!(answer.method(), *method);
|
||||
assert_eq!(serde_json::to_value(&answer).unwrap(), result);
|
||||
}
|
||||
*counts.entry("server requests").or_insert(0) += 1;
|
||||
}
|
||||
for method in ServerNotificationMethod::ALL {
|
||||
let (_, params_schema) = instances.lookup(method.params_type().unwrap()).unwrap();
|
||||
for mode in [Mode::Full, Mode::Minimal] {
|
||||
let params = instances.value(params_schema, mode, 0);
|
||||
let typed: v2::ServerNotification =
|
||||
serde_json::from_value(json!({"method": method.method(), "params": params}))
|
||||
.unwrap();
|
||||
assert_eq!(typed.method(), *method);
|
||||
let encoded = serde_json::to_value(&typed).unwrap();
|
||||
assert!(encoded.get("jsonrpc").is_none() && encoded.get("id").is_none());
|
||||
assert!(
|
||||
server_notification.is_valid(&encoded),
|
||||
"{method}: {encoded}"
|
||||
);
|
||||
}
|
||||
*counts.entry("server notifications").or_insert(0) += 1;
|
||||
}
|
||||
let initialized = serde_json::to_value(v2::ClientNotification::Initialized).unwrap();
|
||||
assert_eq!(initialized, json!({"method": "initialized"}));
|
||||
assert!(client_notification.is_valid(&initialized));
|
||||
eprintln!("typed envelopes checked: {counts:?}");
|
||||
assert_eq!(counts["client requests"], 44);
|
||||
assert_eq!(counts["server requests"], 5);
|
||||
assert_eq!(counts["server notifications"], 33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_c_and_undecided_methods_are_method_not_found_and_never_typed() {
|
||||
let manifest = manifest();
|
||||
let mut refused = 0;
|
||||
for (kind, _, _) in KINDS {
|
||||
for row in manifest["methods"][kind].as_array().unwrap() {
|
||||
if matches!(row["tier"].as_str(), Some("A" | "B")) {
|
||||
continue;
|
||||
}
|
||||
let method = row["method"].as_str().unwrap();
|
||||
assert!(!disposition(kind, method).unwrap().selected());
|
||||
let envelope = json!({"method": method, "params": {}});
|
||||
match kind {
|
||||
"clientRequests" => {
|
||||
assert_eq!(
|
||||
methods::unselected_request_error(method),
|
||||
Some(error_code::METHOD_NOT_FOUND)
|
||||
);
|
||||
assert!(ClientRequestMethod::from_method(method).is_none());
|
||||
assert!(serde_json::from_value::<v2::ClientRequestParams>(envelope).is_err());
|
||||
}
|
||||
"serverRequests" => {
|
||||
assert!(ServerRequestMethod::from_method(method).is_none());
|
||||
assert!(serde_json::from_value::<v2::ServerRequestParams>(envelope).is_err());
|
||||
}
|
||||
"serverNotifications" => {
|
||||
assert!(ServerNotificationMethod::from_method(method).is_none());
|
||||
assert!(serde_json::from_value::<v2::ServerNotification>(envelope).is_err());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
refused += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(error_code::METHOD_NOT_FOUND, -32601);
|
||||
assert_eq!(methods::unselected_request_error("thread/start"), None);
|
||||
assert_eq!(
|
||||
methods::unselected_request_error("not/in/the/export"),
|
||||
Some(error_code::METHOD_NOT_FOUND)
|
||||
);
|
||||
eprintln!("unselected methods refused: {refused}");
|
||||
assert!(refused > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codewhale_extensions_stay_optional_and_valid_upstream() {
|
||||
let instances = Instances::new();
|
||||
let thread = instances.validator("#/definitions/v2/Thread");
|
||||
let (_, schema) = instances.lookup("Thread").unwrap();
|
||||
let mut value = instances.value(schema, Mode::Minimal, 0);
|
||||
let plain: v2::Thread = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(plain.executor.is_none() && plain.environment.is_none());
|
||||
assert_eq!(serde_json::to_value(&plain).unwrap(), value);
|
||||
value["executor"] = json!({"kind": "acp", "agentId": "claude", "agentName": "Claude"});
|
||||
value["environment"] = json!({"id": "env-1", "providerKind": "remote"});
|
||||
assert!(thread.is_valid(&value));
|
||||
let placed: v2::Thread = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(placed.executor.as_ref().unwrap().agent_id, "claude");
|
||||
assert_eq!(serde_json::to_value(&placed).unwrap(), value);
|
||||
let refusal = instances.validator("#/definitions/codewhale/CapabilityRefusalData");
|
||||
let data = serde_json::to_value(v2::CapabilityRefusalData {
|
||||
codewhale_refusal: v2::CapabilityRefusal {
|
||||
code: v2::CapabilityRefusalCode::EnvironmentNotSupported,
|
||||
method: Some("turn/start".into()),
|
||||
thread_id: Some("thread-1".into()),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert!(refusal.is_valid(&data));
|
||||
assert_eq!(
|
||||
data["codewhaleRefusal"]["code"],
|
||||
"environment_not_supported"
|
||||
);
|
||||
assert_eq!(error_code::CAPABILITY_UNSUPPORTED, -32020);
|
||||
assert!(
|
||||
serde_json::from_value::<v2::CapabilityRefusal>(json!({"code": "no_acp", "token": "x"}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ClientInfo": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"InitializeCapabilities": {
|
||||
"description": "Client-declared capabilities negotiated during initialize.",
|
||||
"properties": {
|
||||
"experimentalApi": {
|
||||
"default": false,
|
||||
"description": "Opt into receiving experimental API methods and fields.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"explicitGatewayOauth": {
|
||||
"description": "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensions": {
|
||||
"additionalProperties": true,
|
||||
"description": "MCP extension settings declared by the app-server client.",
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"mcpServerOpenaiFormElicitation": {
|
||||
"description": "Legacy opt-in for the `openai/form` MCP extension.\n\nNew clients should declare `openai/form` in [`Self::extensions`].",
|
||||
"type": "boolean"
|
||||
},
|
||||
"optOutNotificationMethods": {
|
||||
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"requestAttestation": {
|
||||
"default": false,
|
||||
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/InitializeCapabilities"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"clientInfo": {
|
||||
"$ref": "#/definitions/ClientInfo"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"clientInfo"
|
||||
],
|
||||
"title": "InitializeParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"codexHome": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Absolute path to the server's $CODEX_HOME directory."
|
||||
},
|
||||
"platformFamily": {
|
||||
"description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.",
|
||||
"type": "string"
|
||||
},
|
||||
"platformOs": {
|
||||
"description": "Operating system for the running app-server target, for example `\"macos\"`, `\"linux\"`, or `\"windows\"`.",
|
||||
"type": "string"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"codexHome",
|
||||
"platformFamily",
|
||||
"platformOs",
|
||||
"userAgent"
|
||||
],
|
||||
"title": "InitializeResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"JSONRPCErrorError": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"data": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "A response to a request that indicates an error occurred.",
|
||||
"properties": {
|
||||
"error": {
|
||||
"$ref": "#/definitions/JSONRPCErrorError"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error",
|
||||
"id"
|
||||
],
|
||||
"title": "JSONRPCError",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/JSONRPCRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JSONRPCNotification"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JSONRPCResponse"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/JSONRPCError"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"JSONRPCError": {
|
||||
"description": "A response to a request that indicates an error occurred.",
|
||||
"properties": {
|
||||
"error": {
|
||||
"$ref": "#/definitions/JSONRPCErrorError"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"JSONRPCErrorError": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"data": true,
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"JSONRPCNotification": {
|
||||
"description": "A notification which does not expect a response.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"params": true
|
||||
},
|
||||
"required": [
|
||||
"method"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"JSONRPCRequest": {
|
||||
"description": "A request that expects a response.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"params": true,
|
||||
"trace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/W3cTraceContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional W3C Trace Context for distributed tracing."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"JSONRPCResponse": {
|
||||
"description": "A successful (non-error) response to a request.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"result": true
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"result"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"W3cTraceContext": {
|
||||
"properties": {
|
||||
"traceparent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"tracestate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent.",
|
||||
"title": "JSONRPCMessage"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "A notification which does not expect a response.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"params": true
|
||||
},
|
||||
"required": [
|
||||
"method"
|
||||
],
|
||||
"title": "JSONRPCNotification",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"W3cTraceContext": {
|
||||
"properties": {
|
||||
"traceparent": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"tracestate": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "A request that expects a response.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"params": true,
|
||||
"trace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/W3cTraceContext"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional W3C Trace Context for distributed tracing."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "JSONRPCRequest",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "A successful (non-error) response to a request.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"result": true
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"result"
|
||||
],
|
||||
"title": "JSONRPCResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// 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 ClientNotification = { "method": "initialized" };
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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 { FuzzyFileSearchParams } from "./FuzzyFileSearchParams";
|
||||
import type { GetAuthStatusParams } from "./GetAuthStatusParams";
|
||||
import type { GetConversationSummaryParams } from "./GetConversationSummaryParams";
|
||||
import type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams";
|
||||
import type { InitializeParams } from "./InitializeParams";
|
||||
import type { RequestId } from "./RequestId";
|
||||
import type { AppsInstalledParams } from "./v2/AppsInstalledParams";
|
||||
import type { AppsListParams } from "./v2/AppsListParams";
|
||||
import type { AppsReadParams } from "./v2/AppsReadParams";
|
||||
import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams";
|
||||
import type { CommandExecParams } from "./v2/CommandExecParams";
|
||||
import type { CommandExecResizeParams } from "./v2/CommandExecResizeParams";
|
||||
import type { CommandExecTerminateParams } from "./v2/CommandExecTerminateParams";
|
||||
import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams";
|
||||
import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams";
|
||||
import type { ConfigReadParams } from "./v2/ConfigReadParams";
|
||||
import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams";
|
||||
import type { ConsumeAccountRateLimitResetCreditParams } from "./v2/ConsumeAccountRateLimitResetCreditParams";
|
||||
import type { ExperimentalFeatureEnablementSetParams } from "./v2/ExperimentalFeatureEnablementSetParams";
|
||||
import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams";
|
||||
import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams";
|
||||
import type { ExternalAgentConfigImportHistoryRecordParams } from "./v2/ExternalAgentConfigImportHistoryRecordParams";
|
||||
import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams";
|
||||
import type { FeedbackSaveParams } from "./v2/FeedbackSaveParams";
|
||||
import type { FeedbackUploadParams } from "./v2/FeedbackUploadParams";
|
||||
import type { FsCopyParams } from "./v2/FsCopyParams";
|
||||
import type { FsCreateDirectoryParams } from "./v2/FsCreateDirectoryParams";
|
||||
import type { FsGetMetadataParams } from "./v2/FsGetMetadataParams";
|
||||
import type { FsReadDirectoryParams } from "./v2/FsReadDirectoryParams";
|
||||
import type { FsReadFileParams } from "./v2/FsReadFileParams";
|
||||
import type { FsRemoveParams } from "./v2/FsRemoveParams";
|
||||
import type { FsUnwatchParams } from "./v2/FsUnwatchParams";
|
||||
import type { FsWatchParams } from "./v2/FsWatchParams";
|
||||
import type { FsWriteFileParams } from "./v2/FsWriteFileParams";
|
||||
import type { GetAccountParams } from "./v2/GetAccountParams";
|
||||
import type { GetAccountRateLimitsParams } from "./v2/GetAccountRateLimitsParams";
|
||||
import type { GetAccountTokenUsageParams } from "./v2/GetAccountTokenUsageParams";
|
||||
import type { HooksListParams } from "./v2/HooksListParams";
|
||||
import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams";
|
||||
import type { LoginAccountParams } from "./v2/LoginAccountParams";
|
||||
import type { MarketplaceAddParams } from "./v2/MarketplaceAddParams";
|
||||
import type { MarketplaceRemoveParams } from "./v2/MarketplaceRemoveParams";
|
||||
import type { MarketplaceUpgradeParams } from "./v2/MarketplaceUpgradeParams";
|
||||
import type { McpResourceReadParams } from "./v2/McpResourceReadParams";
|
||||
import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams";
|
||||
import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams";
|
||||
import type { ModelListParams } from "./v2/ModelListParams";
|
||||
import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapabilitiesReadParams";
|
||||
import type { PermissionProfileListParams } from "./v2/PermissionProfileListParams";
|
||||
import type { PluginInstallParams } from "./v2/PluginInstallParams";
|
||||
import type { PluginInstalledParams } from "./v2/PluginInstalledParams";
|
||||
import type { PluginListParams } from "./v2/PluginListParams";
|
||||
import type { PluginReadParams } from "./v2/PluginReadParams";
|
||||
import type { PluginReconcileParams } from "./v2/PluginReconcileParams";
|
||||
import type { PluginShareCheckoutParams } from "./v2/PluginShareCheckoutParams";
|
||||
import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams";
|
||||
import type { PluginShareListParams } from "./v2/PluginShareListParams";
|
||||
import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams";
|
||||
import type { PluginShareUpdateTargetsParams } from "./v2/PluginShareUpdateTargetsParams";
|
||||
import type { PluginSkillReadParams } from "./v2/PluginSkillReadParams";
|
||||
import type { PluginUninstallParams } from "./v2/PluginUninstallParams";
|
||||
import type { ReviewStartParams } from "./v2/ReviewStartParams";
|
||||
import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams";
|
||||
import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams";
|
||||
import type { SkillsExtraRootsSetParams } from "./v2/SkillsExtraRootsSetParams";
|
||||
import type { SkillsListParams } from "./v2/SkillsListParams";
|
||||
import type { ThreadApproveGuardianDeniedActionParams } from "./v2/ThreadApproveGuardianDeniedActionParams";
|
||||
import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams";
|
||||
import type { ThreadAttachmentAddParams } from "./v2/ThreadAttachmentAddParams";
|
||||
import type { ThreadAttachmentListParams } from "./v2/ThreadAttachmentListParams";
|
||||
import type { ThreadAttachmentOwnerListParams } from "./v2/ThreadAttachmentOwnerListParams";
|
||||
import type { ThreadAttachmentRemoveParams } from "./v2/ThreadAttachmentRemoveParams";
|
||||
import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams";
|
||||
import type { ThreadDeleteParams } from "./v2/ThreadDeleteParams";
|
||||
import type { ThreadForkParams } from "./v2/ThreadForkParams";
|
||||
import type { ThreadGoalClearParams } from "./v2/ThreadGoalClearParams";
|
||||
import type { ThreadGoalGetParams } from "./v2/ThreadGoalGetParams";
|
||||
import type { ThreadGoalSetParams } from "./v2/ThreadGoalSetParams";
|
||||
import type { ThreadInjectItemsParams } from "./v2/ThreadInjectItemsParams";
|
||||
import type { ThreadItemsListParams } from "./v2/ThreadItemsListParams";
|
||||
import type { ThreadListParams } from "./v2/ThreadListParams";
|
||||
import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams";
|
||||
import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams";
|
||||
import type { ThreadReadParams } from "./v2/ThreadReadParams";
|
||||
import type { ThreadResumeParams } from "./v2/ThreadResumeParams";
|
||||
import type { ThreadRevertParams } from "./v2/ThreadRevertParams";
|
||||
import type { ThreadSectionCreateParams } from "./v2/ThreadSectionCreateParams";
|
||||
import type { ThreadSectionDeleteParams } from "./v2/ThreadSectionDeleteParams";
|
||||
import type { ThreadSectionListParams } from "./v2/ThreadSectionListParams";
|
||||
import type { ThreadSectionMoveParams } from "./v2/ThreadSectionMoveParams";
|
||||
import type { ThreadSectionUpdateParams } from "./v2/ThreadSectionUpdateParams";
|
||||
import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams";
|
||||
import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams";
|
||||
import type { ThreadStartParams } from "./v2/ThreadStartParams";
|
||||
import type { ThreadTurnsListParams } from "./v2/ThreadTurnsListParams";
|
||||
import type { ThreadUnarchiveParams } from "./v2/ThreadUnarchiveParams";
|
||||
import type { ThreadUnsubscribeParams } from "./v2/ThreadUnsubscribeParams";
|
||||
import type { TurnInterruptParams } from "./v2/TurnInterruptParams";
|
||||
import type { TurnStartParams } from "./v2/TurnStartParams";
|
||||
import type { TurnSteerParams } from "./v2/TurnSteerParams";
|
||||
import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupStartParams";
|
||||
|
||||
/**
|
||||
* Request from the client to the server.
|
||||
*/
|
||||
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/attachment/add", id: RequestId, params: ThreadAttachmentAddParams, } | { "method": "thread/attachment/list", id: RequestId, params: ThreadAttachmentListParams, } | { "method": "thread/attachmentOwner/list", id: RequestId, params: ThreadAttachmentOwnerListParams, } | { "method": "thread/attachment/remove", id: RequestId, params: ThreadAttachmentRemoveParams, } | { "method": "thread/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/revert", id: RequestId, params: ThreadRevertParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "method": "threadSection/create", id: RequestId, params: ThreadSectionCreateParams, } | { "method": "threadSection/update", id: RequestId, params: ThreadSectionUpdateParams, } | { "method": "threadSection/delete", id: RequestId, params: ThreadSectionDeleteParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/items/list", id: RequestId, params: ThreadItemsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/reconcile", id: RequestId, params: PluginReconcileParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigLine truncated
|
||||
@@ -1,93 +0,0 @@
|
||||
// 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 { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification";
|
||||
import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification";
|
||||
import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification";
|
||||
import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimitsUpdatedNotification";
|
||||
import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification";
|
||||
import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification";
|
||||
import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification";
|
||||
import type { AuthRecoveryNotification } from "./v2/AuthRecoveryNotification";
|
||||
import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification";
|
||||
import type { CommandExecutionOutputDeltaNotification } from "./v2/CommandExecutionOutputDeltaNotification";
|
||||
import type { ConfigWarningNotification } from "./v2/ConfigWarningNotification";
|
||||
import type { ContextCompactedNotification } from "./v2/ContextCompactedNotification";
|
||||
import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification";
|
||||
import type { EnvironmentConnectionNotification } from "./v2/EnvironmentConnectionNotification";
|
||||
import type { ErrorNotification } from "./v2/ErrorNotification";
|
||||
import type { ExternalAgentConfigImportCompletedNotification } from "./v2/ExternalAgentConfigImportCompletedNotification";
|
||||
import type { ExternalAgentConfigImportProgressNotification } from "./v2/ExternalAgentConfigImportProgressNotification";
|
||||
import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification";
|
||||
import type { FileChangePatchUpdatedNotification } from "./v2/FileChangePatchUpdatedNotification";
|
||||
import type { FsChangedNotification } from "./v2/FsChangedNotification";
|
||||
import type { GatewayOAuthChangedNotification } from "./v2/GatewayOAuthChangedNotification";
|
||||
import type { GuardianWarningNotification } from "./v2/GuardianWarningNotification";
|
||||
import type { HookCompletedNotification } from "./v2/HookCompletedNotification";
|
||||
import type { HookStartedNotification } from "./v2/HookStartedNotification";
|
||||
import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification";
|
||||
import type { ItemGuardianApprovalReviewCompletedNotification } from "./v2/ItemGuardianApprovalReviewCompletedNotification";
|
||||
import type { ItemGuardianApprovalReviewStartedNotification } from "./v2/ItemGuardianApprovalReviewStartedNotification";
|
||||
import type { ItemStartedNotification } from "./v2/ItemStartedNotification";
|
||||
import type { McpServerEventStreamNotification } from "./v2/McpServerEventStreamNotification";
|
||||
import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification";
|
||||
import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification";
|
||||
import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification";
|
||||
import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification";
|
||||
import type { ModelSafetyBufferingUpdatedNotification } from "./v2/ModelSafetyBufferingUpdatedNotification";
|
||||
import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification";
|
||||
import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification";
|
||||
import type { ProcessExitedNotification } from "./v2/ProcessExitedNotification";
|
||||
import type { ProcessOutputDeltaNotification } from "./v2/ProcessOutputDeltaNotification";
|
||||
import type { ProjectChangedNotification } from "./v2/ProjectChangedNotification";
|
||||
import type { RawResponseCompletedNotification } from "./v2/RawResponseCompletedNotification";
|
||||
import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification";
|
||||
import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification";
|
||||
import type { ReasoningSummaryTextDeltaNotification } from "./v2/ReasoningSummaryTextDeltaNotification";
|
||||
import type { ReasoningTextDeltaNotification } from "./v2/ReasoningTextDeltaNotification";
|
||||
import type { RemoteControlStatusChangedNotification } from "./v2/RemoteControlStatusChangedNotification";
|
||||
import type { ServerRequestResolvedNotification } from "./v2/ServerRequestResolvedNotification";
|
||||
import type { SkillsChangedNotification } from "./v2/SkillsChangedNotification";
|
||||
import type { StrictReviewRequiredNotification } from "./v2/StrictReviewRequiredNotification";
|
||||
import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification";
|
||||
import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification";
|
||||
import type { ThreadAttachmentUpdatedNotification } from "./v2/ThreadAttachmentUpdatedNotification";
|
||||
import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification";
|
||||
import type { ThreadDeletedNotification } from "./v2/ThreadDeletedNotification";
|
||||
import type { ThreadGoalClearedNotification } from "./v2/ThreadGoalClearedNotification";
|
||||
import type { ThreadGoalUpdatedNotification } from "./v2/ThreadGoalUpdatedNotification";
|
||||
import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification";
|
||||
import type { ThreadPredictionUpdatedNotification } from "./v2/ThreadPredictionUpdatedNotification";
|
||||
import type { ThreadProjectUpdatedNotification } from "./v2/ThreadProjectUpdatedNotification";
|
||||
import type { ThreadQueueChangedNotification } from "./v2/ThreadQueueChangedNotification";
|
||||
import type { ThreadReadStateChangedNotification } from "./v2/ThreadReadStateChangedNotification";
|
||||
import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification";
|
||||
import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNotification";
|
||||
import type { ThreadRealtimeItemAddedNotification } from "./v2/ThreadRealtimeItemAddedNotification";
|
||||
import type { ThreadRealtimeItemCompletedNotification } from "./v2/ThreadRealtimeItemCompletedNotification";
|
||||
import type { ThreadRealtimeItemStartedNotification } from "./v2/ThreadRealtimeItemStartedNotification";
|
||||
import type { ThreadRealtimeItemTranscriptDeltaNotification } from "./v2/ThreadRealtimeItemTranscriptDeltaNotification";
|
||||
import type { ThreadRealtimeOutputAudioDeltaNotification } from "./v2/ThreadRealtimeOutputAudioDeltaNotification";
|
||||
import type { ThreadRealtimeSdpNotification } from "./v2/ThreadRealtimeSdpNotification";
|
||||
import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification";
|
||||
import type { ThreadRealtimeTranscriptDeltaNotification } from "./v2/ThreadRealtimeTranscriptDeltaNotification";
|
||||
import type { ThreadRealtimeTranscriptDoneNotification } from "./v2/ThreadRealtimeTranscriptDoneNotification";
|
||||
import type { ThreadRevertedNotification } from "./v2/ThreadRevertedNotification";
|
||||
import type { ThreadSettingsUpdatedNotification } from "./v2/ThreadSettingsUpdatedNotification";
|
||||
import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification";
|
||||
import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification";
|
||||
import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification";
|
||||
import type { ThreadUnarchivedNotification } from "./v2/ThreadUnarchivedNotification";
|
||||
import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification";
|
||||
import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification";
|
||||
import type { TurnModerationMetadataNotification } from "./v2/TurnModerationMetadataNotification";
|
||||
import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification";
|
||||
import type { TurnStartedNotification } from "./v2/TurnStartedNotification";
|
||||
import type { WarningNotification } from "./v2/WarningNotification";
|
||||
import type { WindowsSandboxSetupCompletedNotification } from "./v2/WindowsSandboxSetupCompletedNotification";
|
||||
import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldWritableWarningNotification";
|
||||
|
||||
/**
|
||||
* Notification sent from the server to the client.
|
||||
*/
|
||||
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/readState/changed", "params": ThreadReadStateChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "thread/reverted", "params": ThreadRevertedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/attachment/updated", "params": ThreadAttachmentUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/prediction/updated", "params": ThreadPredictionUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/queue/changed", "params": ThreadQueueChangedNotification } | { "method": "project/changed", "params": ProjectChangedNotification } | { "method": "thread/project/updated", "params": ThreadProjectUpdatedNotification } | { "method": "thread/environment/connected", "params": EnvironmentConnectionNotification } | { "method": "thread/environment/disconnected", "params": EnvironmentConnectionNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "autoApprovalReview/strictReviewRequired", "params": StrictReviewRequiredNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "rawResponse/completed", "params": RawResponseCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "mcpServer/event/stream/notification", "params": McpServerEventStreamNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/gatewayOAuth/changed", "params": GatewayOAuthChangedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method":Line truncated
|
||||
@@ -1,19 +0,0 @@
|
||||
// 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 { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams";
|
||||
import type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams";
|
||||
import type { RequestId } from "./RequestId";
|
||||
import type { AttestationGenerateParams } from "./v2/AttestationGenerateParams";
|
||||
import type { ChatgptAuthTokensRefreshParams } from "./v2/ChatgptAuthTokensRefreshParams";
|
||||
import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutionRequestApprovalParams";
|
||||
import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams";
|
||||
import type { FileChangeRequestApprovalParams } from "./v2/FileChangeRequestApprovalParams";
|
||||
import type { McpServerElicitationRequestParams } from "./v2/McpServerElicitationRequestParams";
|
||||
import type { PermissionsRequestApprovalParams } from "./v2/PermissionsRequestApprovalParams";
|
||||
import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams";
|
||||
|
||||
/**
|
||||
* Request initiated from the server and sent to the client.
|
||||
*/
|
||||
export type ServerRequest ={ "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, };
|
||||
-27737
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,127 +0,0 @@
|
||||
#![cfg(feature = "schema")]
|
||||
|
||||
use codewhale_app_server_protocol::runtime::{
|
||||
DynamicToolCallResult, DynamicToolSpec, RuntimeEventEnvelope, RuntimeImageInput,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn runtime_schema_matches_real_nullable_and_flattened_wire_fields() {
|
||||
let schema = codewhale_app_server_protocol::export::runtime_schema();
|
||||
let definitions = schema["$defs"].as_object().unwrap();
|
||||
let event = &definitions["RuntimeEventEnvelope"];
|
||||
let turn = &event["properties"]["turn_id"];
|
||||
let nullable = turn["type"]
|
||||
.as_array()
|
||||
.is_some_and(|types| types.iter().any(|ty| ty == "null"))
|
||||
|| turn["anyOf"]
|
||||
.as_array()
|
||||
.is_some_and(|types| types.iter().any(|ty| ty["type"] == "null"));
|
||||
assert!(nullable);
|
||||
let required = event["required"].as_array().unwrap();
|
||||
for field in ["schema_version", "turn_id", "item_id", "payload"] {
|
||||
assert!(required.iter().any(|name| name == field));
|
||||
}
|
||||
assert!(!required.iter().any(|name| name == "created_at"));
|
||||
assert_eq!(event["properties"]["created_at"]["type"], "string");
|
||||
for name in ["RuntimeCapabilities", "RuntimeExperimentalCapabilities"] {
|
||||
let record = &definitions[name];
|
||||
let required = record["required"].as_array().unwrap();
|
||||
for field in record["properties"].as_object().unwrap().keys() {
|
||||
assert!(
|
||||
required
|
||||
.iter()
|
||||
.any(|name| name.as_str() == Some(field.as_str()))
|
||||
);
|
||||
}
|
||||
}
|
||||
let spec = &definitions["DynamicToolSpec"];
|
||||
assert!(
|
||||
!spec["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|name| name == "defer_loading")
|
||||
);
|
||||
let published_spec = &definitions["DynamicToolSpecOutput"];
|
||||
assert!(
|
||||
published_spec["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|name| name == "defer_loading")
|
||||
);
|
||||
assert_eq!(published_spec["properties"]["namespace"]["type"], "string");
|
||||
assert!(
|
||||
!definitions["DynamicToolCallResult"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|name| name == "content")
|
||||
);
|
||||
let input: DynamicToolSpec = serde_json::from_value(
|
||||
serde_json::json!({"name":"tool","description":"fixture","input_schema":{}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!input.defer_loading);
|
||||
let result: DynamicToolCallResult =
|
||||
serde_json::from_value(serde_json::json!({"success":false})).unwrap();
|
||||
assert!(result.content.is_empty());
|
||||
assert!(
|
||||
serde_json::from_value::<RuntimeImageInput>(
|
||||
serde_json::json!({"mime":"image/png","dataBase64":"","extra":true})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(event["properties"].get("extra").is_none());
|
||||
assert_eq!(
|
||||
definitions["RuntimeImageInput"]["additionalProperties"],
|
||||
false
|
||||
);
|
||||
let value = serde_json::to_value(RuntimeEventEnvelope {
|
||||
schema_version: 1,
|
||||
seq: 4,
|
||||
event: "future.name".into(),
|
||||
kind: "future.name".into(),
|
||||
thread_id: "thread".into(),
|
||||
turn_id: None,
|
||||
item_id: None,
|
||||
timestamp: "now".into(),
|
||||
created_at: None,
|
||||
payload: serde_json::json!({"receipt": null}),
|
||||
extra: BTreeMap::new(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(value["turn_id"].is_null());
|
||||
assert!(value.get("created_at").is_none());
|
||||
let typescript = codewhale_app_server_protocol::export::runtime_typescript(&schema);
|
||||
assert!(typescript.contains("turn_id: (string | null)"));
|
||||
assert!(!typescript.contains("turn_id?:"));
|
||||
assert!(typescript.contains("created_at?: string"));
|
||||
assert!(typescript.contains("export type RuntimeImageInput ="));
|
||||
assert!(typescript.contains("Json[] | { [key: string]: Json | undefined }"));
|
||||
assert!(typescript.contains("type: 'input_text'"));
|
||||
assert!(typescript.contains("dataBase64: string"));
|
||||
assert!(typescript.contains("[key: string]: Json | undefined"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_runtime_schema_and_sdk_exports_match_one_rust_contract() {
|
||||
let package = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let repository = package.parent().and_then(std::path::Path::parent).unwrap();
|
||||
let schema = codewhale_app_server_protocol::export::runtime_schema();
|
||||
let json = serde_json::to_string_pretty(&schema).unwrap() + "\n";
|
||||
let typescript = codewhale_app_server_protocol::export::runtime_typescript(&schema);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(package.join("schema/json/runtime.schema.json")).unwrap(),
|
||||
json
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(package.join("schema/typescript/runtime.d.ts")).unwrap(),
|
||||
typescript
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(repository.join("npm/runtime-sdk/generated/runtime.d.ts")).unwrap(),
|
||||
typescript
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
use codewhale_app_server_protocol::rpc::{
|
||||
RequestId, RpcCall, RpcErrorDetails, RpcErrorResponse, RpcMessage, RpcResponse,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn correlated_rpc_envelopes_match_pinned_stable_schema() {
|
||||
let request_schema: Value =
|
||||
serde_json::from_str(include_str!("pinned/JSONRPCRequest.json")).unwrap();
|
||||
let response_schema: Value =
|
||||
serde_json::from_str(include_str!("pinned/JSONRPCResponse.json")).unwrap();
|
||||
let error_schema: Value =
|
||||
serde_json::from_str(include_str!("pinned/JSONRPCError.json")).unwrap();
|
||||
let message_schema: Value =
|
||||
serde_json::from_str(include_str!("pinned/JSONRPCMessage.json")).unwrap();
|
||||
let notification_schema: Value =
|
||||
serde_json::from_str(include_str!("pinned/JSONRPCNotification.json")).unwrap();
|
||||
let message_validator = jsonschema::validator_for(&message_schema).unwrap();
|
||||
let notification_validator = jsonschema::validator_for(¬ification_schema).unwrap();
|
||||
let request_validator = jsonschema::validator_for(&request_schema).unwrap();
|
||||
let response_validator = jsonschema::validator_for(&response_schema).unwrap();
|
||||
let error_validator = jsonschema::validator_for(&error_schema).unwrap();
|
||||
for id in [
|
||||
RequestId::String(String::new()),
|
||||
RequestId::String("request/string".into()),
|
||||
RequestId::Integer(i64::MIN),
|
||||
RequestId::Integer(i64::MAX),
|
||||
] {
|
||||
let call: RpcCall = serde_json::from_value(json!({"id":id,"method":"initialize","params":{"clientInfo":{"name":"fixture","version":"1"}}})).unwrap();
|
||||
let request = serde_json::to_value(call).unwrap();
|
||||
assert!(request.get("jsonrpc").is_none());
|
||||
assert!(request_validator.is_valid(&request));
|
||||
assert!(message_validator.is_valid(&request));
|
||||
assert!(matches!(
|
||||
serde_json::from_value::<RpcMessage>(request).unwrap(),
|
||||
RpcMessage::Request(_)
|
||||
));
|
||||
let response = serde_json::to_value(RpcResponse {
|
||||
id: id.clone(),
|
||||
result: json!({}),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(response_validator.is_valid(&response));
|
||||
assert!(message_validator.is_valid(&response));
|
||||
assert!(matches!(
|
||||
serde_json::from_value::<RpcMessage>(response).unwrap(),
|
||||
RpcMessage::Response(_)
|
||||
));
|
||||
let error = serde_json::to_value(RpcErrorResponse {
|
||||
id,
|
||||
error: RpcErrorDetails {
|
||||
code: -32600,
|
||||
data: None,
|
||||
message: "Not initialized".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert!(error_validator.is_valid(&error));
|
||||
assert!(error["error"].get("data").is_none());
|
||||
assert!(message_validator.is_valid(&error));
|
||||
assert!(matches!(
|
||||
serde_json::from_value::<RpcMessage>(error).unwrap(),
|
||||
RpcMessage::Error(_)
|
||||
));
|
||||
}
|
||||
let notification: RpcMessage = serde_json::from_value(json!({"method":"initialized"})).unwrap();
|
||||
let value = serde_json::to_value(notification).unwrap();
|
||||
assert!(notification_validator.is_valid(&value));
|
||||
assert!(message_validator.is_valid(&value));
|
||||
assert!(value.get("id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_call_preserves_notification_absence_and_refuses_non_ids() {
|
||||
let notification: RpcCall =
|
||||
serde_json::from_value(json!({"jsonrpc":"2.0","method":"initialized"})).unwrap();
|
||||
let value = serde_json::to_value(notification).unwrap();
|
||||
assert!(value.get("id").is_none());
|
||||
assert!(value.get("jsonrpc").is_none());
|
||||
for id in [
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!(1.0),
|
||||
json!(u64::MAX),
|
||||
json!({}),
|
||||
json!([]),
|
||||
] {
|
||||
assert!(serde_json::from_value::<RequestId>(id.clone()).is_err());
|
||||
let call: RpcCall = serde_json::from_value(json!({"id":id,"method":"shutdown"})).unwrap();
|
||||
assert!(call.id.is_none());
|
||||
}
|
||||
for version in [json!(null), json!(true), json!("1.0")] {
|
||||
let call: RpcCall =
|
||||
serde_json::from_value(json!({"jsonrpc":version,"id":1,"method":"initialize"}))
|
||||
.unwrap();
|
||||
assert_eq!(call.id, Some(RequestId::Integer(1)));
|
||||
assert!(serde_json::to_value(call).unwrap().get("jsonrpc").is_none());
|
||||
}
|
||||
assert!(serde_json::from_value::<RpcCall>(json!({"id":1,"method":7})).is_err());
|
||||
assert!(serde_json::from_value::<RpcMessage>(json!({"id":null,"result":{}})).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<RpcMessage>(
|
||||
json!({"id":null,"error":{"code":-32600,"message":"invalid"}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_value::<RpcCall>(json!({"id":1,"result":{}})).is_err());
|
||||
for params in [json!({}), json!(null), json!(["value"])] {
|
||||
let message: RpcMessage =
|
||||
serde_json::from_value(json!({"id":1,"method":"fixture","params":params})).unwrap();
|
||||
let encoded = serde_json::to_value(message).unwrap();
|
||||
if params.is_null() {
|
||||
assert!(encoded.get("params").is_none());
|
||||
} else {
|
||||
assert_eq!(encoded["params"], params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialization_contract_matches_pinned_stable_schema() {
|
||||
use codewhale_app_server_protocol::lifecycle::{InitializeParams, InitializeResponse};
|
||||
let params_schema: Value =
|
||||
serde_json::from_str(include_str!("pinned/InitializeParams.json")).unwrap();
|
||||
let result_schema: Value =
|
||||
serde_json::from_str(include_str!("pinned/InitializeResponse.json")).unwrap();
|
||||
let params: InitializeParams = serde_json::from_value(json!({"clientInfo":{"name":"fixture","version":"1"},"capabilities":{"experimentalApi":false,"optOutNotificationMethods":null}})).unwrap();
|
||||
assert!(
|
||||
jsonschema::validator_for(¶ms_schema)
|
||||
.unwrap()
|
||||
.is_valid(&serde_json::to_value(params).unwrap())
|
||||
);
|
||||
let result = InitializeResponse {
|
||||
user_agent: "Codewhale/fixture".into(),
|
||||
codex_home: std::path::PathBuf::from("/fixture/codewhale"),
|
||||
platform_family: "unix".into(),
|
||||
platform_os: "macos".into(),
|
||||
};
|
||||
let value = serde_json::to_value(result).unwrap();
|
||||
assert!(
|
||||
jsonschema::validator_for(&result_schema)
|
||||
.unwrap()
|
||||
.is_valid(&value)
|
||||
);
|
||||
assert_eq!(value["codexHome"], "/fixture/codewhale");
|
||||
}
|
||||
@@ -8,41 +8,24 @@ repository.workspace = true
|
||||
description = "App-server transport for Codewhale runtime integrations"
|
||||
# `codewhale app-server` is owned by codewhale-cli; this crate is library-only.
|
||||
autobins = false
|
||||
exclude = [
|
||||
"tests/**",
|
||||
"src/**/tests.rs",
|
||||
"src/**/tests/**",
|
||||
"src/**/*_tests.rs",
|
||||
"src/**/*test_support.rs",
|
||||
"src/runtime_web/app.test.mjs",
|
||||
"src/runtime_web/orca-evidence.html",
|
||||
]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-support = ["codewhale-engine/test-support"]
|
||||
|
||||
[dependencies]
|
||||
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
|
||||
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.10.3" }
|
||||
codewhale-config = { path = "../config", version = "0.10.3" }
|
||||
codewhale-core = { path = "../core", version = "0.10.3" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
|
||||
codewhale-palette = { path = "../palette", version = "0.10.3", default-features = false }
|
||||
codewhale-paths = { path = "../paths", version = "0.10.3" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
|
||||
codewhale-release = { path = "../release", version = "0.10.3" }
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.3" }
|
||||
codewhale-state = { path = "../state", version = "0.10.3" }
|
||||
codewhale-agent = { path = "../agent", version = "0.10.2" }
|
||||
codewhale-config = { path = "../config", version = "0.10.2" }
|
||||
codewhale-core = { path = "../core", version = "0.10.2" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.10.2" }
|
||||
codewhale-paths = { path = "../paths", version = "0.10.2" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.10.2" }
|
||||
codewhale-release = { path = "../release", version = "0.10.2" }
|
||||
codewhale-state = { path = "../state", version = "0.10.2" }
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
|
||||
serde_json.workspace = true
|
||||
rustls.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -50,75 +33,14 @@ tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
uuid.workspace = true
|
||||
# The store-derived control endpoint hashes the runtime store root
|
||||
# (`daemon_socket::store_selected_socket_path`).
|
||||
sha2.workspace = true
|
||||
async-stream = "0.3.6"
|
||||
base64 = "0.23.1"
|
||||
clap.workspace = true
|
||||
codewhale-command-contract = { path = "../command-contract", version = "0.10.3" }
|
||||
codewhale-dependencies = { path = "../dependencies", version = "0.10.3" }
|
||||
codewhale-engine = { path = "../engine", version = "0.10.3" }
|
||||
codewhale-error-taxonomy = { path = "../error-taxonomy", version = "0.10.3" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
|
||||
codewhale-lane = { path = "../lane", version = "0.10.3" }
|
||||
codewhale-localization = { path = "../localization", version = "0.10.3" }
|
||||
codewhale-lsp = { path = "../lsp", version = "0.10.3" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.10.3" }
|
||||
codewhale-memory = { path = "../memory", version = "0.10.3" }
|
||||
codewhale-models = { path = "../models", version = "0.10.3" }
|
||||
codewhale-net = { path = "../net", version = "0.10.3" }
|
||||
codewhale-notify = { path = "../notify", version = "0.10.3" }
|
||||
codewhale-plugins = { path = "../plugins", version = "0.10.3" }
|
||||
codewhale-sandbox = { path = "../sandbox", version = "0.10.3" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
|
||||
codewhale-skill-install = { path = "../skill-install", version = "0.10.3" }
|
||||
codewhale-skills = { path = "../skills", version = "0.10.3" }
|
||||
codewhale-snapshot = { path = "../snapshot", version = "0.10.3" }
|
||||
codewhale-telemetry = { path = "../telemetry", version = "0.10.3" }
|
||||
codewhale-tools = { path = "../tools", version = "0.10.3" }
|
||||
codewhale-tui-kit = { path = "../tui-kit", version = "0.10.3" }
|
||||
codewhale-util = { path = "../util", version = "0.10.3" }
|
||||
colored = "3.0.0"
|
||||
futures-util = "0.3.31"
|
||||
tokio-tungstenite = "0.30"
|
||||
ignore = "0.4"
|
||||
parking_lot = "0.12"
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
regex = "1.11"
|
||||
rmcp = { version = "3.9.0", default-features = false, features = ["auth", "client", "server", "transport-io"] }
|
||||
semver.workspace = true
|
||||
tempfile.workspace = true
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
toml.workspace = true
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
webbrowser = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
rusqlite.workspace = true
|
||||
tempfile.workspace = true
|
||||
tower = "0.5"
|
||||
async-trait.workspace = true
|
||||
codewhale-agent-contract = { path = "../agent-contract", version = "0.10.3", features = [
|
||||
"test-support",
|
||||
] }
|
||||
codewhale-config = { path = "../config", version = "0.10.3", features = ["schema", "test-support"] }
|
||||
codewhale-engine = { path = "../engine", version = "0.10.3", features = ["test-support"] }
|
||||
codewhale-notify = { path = "../notify", version = "0.10.3", features = ["test-thread-scoped-state"] }
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.3", features = [
|
||||
"test-support",
|
||||
"test-thread-scoped-state",
|
||||
] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
rquickjs.workspace = true
|
||||
rustls.workspace = true
|
||||
wiremock = "0.6"
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_Pipes"] }
|
||||
|
||||
[build-dependencies]
|
||||
codewhale-build-support = { path = "../build-support", version = "0.10.3" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -1,10 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(
|
||||
std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"),
|
||||
);
|
||||
println!("cargo:rerun-if-env-changed=CARGO_MANIFEST_DIR");
|
||||
codewhale_build_support::declare_rerun_conditions(&manifest_dir);
|
||||
codewhale_build_support::emit_build_version(&manifest_dir, env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
#[cfg(test)]
|
||||
use crate::stdio::{emit_stdio_event, interrupt_turn_request};
|
||||
#[cfg(test)]
|
||||
use crate::turns::{RuntimeTurnInput, extract_runtime_thread_id, turn_terminal_status};
|
||||
use crate::{AppState, DispatchError, RuntimeBridge, SharedRuntimeBridge, thread_control};
|
||||
#[cfg(test)]
|
||||
use crate::{
|
||||
InFlightTurn, MAX_SSE_FRAME_BYTES, RuntimeThreadHint, TurnExecutionOwner, TurnRegistry,
|
||||
TurnTerminalStatus, TurnTranscript,
|
||||
};
|
||||
use anyhow::Context as _;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
#[cfg(test)]
|
||||
use codewhale_protocol::{EventFrame, ResponseChannel};
|
||||
use serde_json::{Value, json};
|
||||
#[cfg(test)]
|
||||
use std::collections::HashMap;
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
#[cfg(test)]
|
||||
use std::path::PathBuf;
|
||||
#[cfg(test)]
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(all(test, unix))]
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
#[cfg(test)]
|
||||
use tokio::io::AsyncWrite;
|
||||
#[cfg(all(test, unix))]
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Historical cold-child cache comparator. It has no production producer.
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) async fn acquire_historical_runtime_bridge<F, Fut>(
|
||||
state: &AppState,
|
||||
start: F,
|
||||
) -> std::result::Result<SharedRuntimeBridge, DispatchError>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<RuntimeBridge>>,
|
||||
{
|
||||
if let Some(bridge) = state.runtime_bridge.lock().await.as_ref() {
|
||||
return Ok(bridge.clone());
|
||||
}
|
||||
if state.captured_owner.is_some() {
|
||||
return Err(DispatchError::runtime_unavailable(
|
||||
"the captured Runtime owner is unavailable; refusing another owner",
|
||||
));
|
||||
}
|
||||
let bridge =
|
||||
Arc::new(Mutex::new(start().await.map_err(|err| {
|
||||
DispatchError::runtime_unavailable(err.to_string())
|
||||
})?));
|
||||
let mut slot = state.runtime_bridge.lock().await;
|
||||
// Prefer a bridge cached by a concurrent caller while we were spawning;
|
||||
// dropping our unused one kills the extra child via `Drop`.
|
||||
Ok(slot.get_or_insert_with(|| bridge.clone()).clone())
|
||||
}
|
||||
|
||||
/// Use the exact bridge installed by the held canonical owner. No process,
|
||||
/// store or transcript can be created when this captured connection is absent.
|
||||
pub(crate) async fn acquire_runtime_bridge(
|
||||
state: &AppState,
|
||||
) -> std::result::Result<SharedRuntimeBridge, DispatchError> {
|
||||
state
|
||||
.runtime_bridge
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
DispatchError::runtime_unavailable(
|
||||
"the captured Runtime owner is unavailable; refusing another owner",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn acquire_live_runtime_bridge(
|
||||
state: &AppState,
|
||||
) -> std::result::Result<tokio::sync::OwnedMutexGuard<RuntimeBridge>, DispatchError> {
|
||||
Ok(acquire_runtime_bridge(state).await?.lock_owned().await)
|
||||
}
|
||||
|
||||
/// Historical respawn comparison; all actual frontends use the held owner.
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) async fn acquire_live_runtime_bridge_with<F, Fut>(
|
||||
state: &AppState,
|
||||
start: F,
|
||||
) -> std::result::Result<tokio::sync::OwnedMutexGuard<RuntimeBridge>, DispatchError>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<RuntimeBridge>>,
|
||||
{
|
||||
for _ in 0..2 {
|
||||
let shared = acquire_historical_runtime_bridge(state, &start).await?;
|
||||
let mut bridge = shared.clone().lock_owned().await;
|
||||
if !bridge.child_exited() {
|
||||
return Ok(bridge);
|
||||
}
|
||||
drop(bridge);
|
||||
let mut slot = state.runtime_bridge.lock().await;
|
||||
// Evict only the dead bridge: a concurrent caller may already have
|
||||
// replaced it with a live one.
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|cached| Arc::ptr_eq(cached, &shared))
|
||||
{
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
Err(DispatchError::runtime_unavailable(
|
||||
"runtime API bridge exited immediately after starting",
|
||||
))
|
||||
}
|
||||
|
||||
/// Historical config/cache comparator. Captured owners retain their bridge.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn invalidate_runtime_bridge(state: &AppState) {
|
||||
if state.captured_owner.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut bridge = state.runtime_bridge.lock().await;
|
||||
*bridge = None;
|
||||
}
|
||||
|
||||
impl RuntimeBridge {
|
||||
/// The child binds an ephemeral loopback port itself (`--port 0`) and
|
||||
/// reports it on stdout; the parent never reserves a port for it to race.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn runtime_command(config_path: Option<&Path>, auth_token: &str) -> Result<Command> {
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
let mut command = if let Some(path) = current_exe {
|
||||
Command::new(path)
|
||||
} else {
|
||||
Command::new("codewhale")
|
||||
};
|
||||
// Pass the runtime auth token out-of-band via env (not argv) so local
|
||||
// `ps` cannot read credential material from the child command line.
|
||||
// The TUI/runtime server already accepts CODEWHALE_RUNTIME_TOKEN /
|
||||
// DEEPSEEK_RUNTIME_TOKEN when --auth-token is absent.
|
||||
command
|
||||
.arg("app-server")
|
||||
.arg("--http")
|
||||
.arg("--host")
|
||||
.arg("127.0.0.1")
|
||||
.arg("--port")
|
||||
.arg("0")
|
||||
.env("CODEWHALE_RUNTIME_TOKEN", auth_token)
|
||||
.env("DEEPSEEK_RUNTIME_TOKEN", auth_token)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
if let Some(config_path) = config_path {
|
||||
command.arg("--config").arg(config_path);
|
||||
}
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(crate) fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
match self.auth_token.as_deref() {
|
||||
Some(token) => builder.bearer_auth(token),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn request_json(&self, builder: reqwest::RequestBuilder) -> Result<Value> {
|
||||
thread_control::read_json_response(builder.send().await?).await
|
||||
}
|
||||
|
||||
/// Resolve `stdio_thread_id` to a runtime thread, minting one only when
|
||||
/// `thread_map` has no entry. The map lives on [`AppState`] and outlives
|
||||
/// this bridge, so a thread created under a previous child keeps its id
|
||||
/// here as long as the store it was persisted to is shared (#6246).
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn ensure_runtime_thread(
|
||||
&mut self,
|
||||
thread_map: &mut HashMap<String, String>,
|
||||
stdio_thread_id: &str,
|
||||
hint: Option<RuntimeThreadHint>,
|
||||
) -> Result<String> {
|
||||
if let Some(runtime_thread_id) = thread_map.get(stdio_thread_id) {
|
||||
return Ok(runtime_thread_id.clone());
|
||||
}
|
||||
let hint = hint.unwrap_or_default();
|
||||
let runtime_thread_id = self
|
||||
.create_runtime_thread(hint.model, hint.workspace)
|
||||
.await?;
|
||||
thread_map.insert(stdio_thread_id.to_string(), runtime_thread_id.clone());
|
||||
Ok(runtime_thread_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn create_runtime_thread(
|
||||
&mut self,
|
||||
model: Option<String>,
|
||||
workspace: Option<PathBuf>,
|
||||
) -> Result<String> {
|
||||
let record = self
|
||||
.request_json(
|
||||
self.authed(self.client.post(format!("{}/v1/threads", self.base_url)))
|
||||
.json(&json!({
|
||||
"model": model,
|
||||
"workspace": workspace,
|
||||
"mode": "agent",
|
||||
"archived": false,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let thread_id = extract_runtime_thread_id(&record)?.to_string();
|
||||
self.last_seq_by_thread
|
||||
.entry(thread_id.clone())
|
||||
.or_insert(0);
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
/// Run one turn to completion, streaming its events to `writer`.
|
||||
///
|
||||
/// `registration` is `Some` on the stdio path: it publishes the live turn
|
||||
/// so an `thread/interrupt` arriving mid-stream can reach the runtime
|
||||
/// without waiting on the bridge mutex this call holds.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn message_thread<W: AsyncWrite + Unpin>(
|
||||
&mut self,
|
||||
thread_id: &str,
|
||||
input: RuntimeTurnInput<'_>,
|
||||
writer: &mut W,
|
||||
registration: Option<(TurnRegistry, String)>,
|
||||
mut transcript: Option<&mut TurnTranscript>,
|
||||
) -> Result<Value> {
|
||||
let RuntimeTurnInput {
|
||||
input,
|
||||
images,
|
||||
max_output_tokens,
|
||||
expected_workspace,
|
||||
cancellation: _,
|
||||
} = input;
|
||||
let mut request = json!({ "prompt": input, "allowed_tools": [] });
|
||||
if let Some(workspace) = expected_workspace {
|
||||
request["expected_workspace"] = json!(workspace);
|
||||
}
|
||||
if !images.is_empty() {
|
||||
let info = self
|
||||
.request_json(
|
||||
self.authed(
|
||||
self.client
|
||||
.get(format!("{}/v1/runtime/info", self.base_url)),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if info
|
||||
.pointer("/capabilities/turn_image_inputs")
|
||||
.and_then(Value::as_bool)
|
||||
!= Some(true)
|
||||
{
|
||||
bail!(
|
||||
"Runtime image input is unavailable; update the Runtime before sending attachments"
|
||||
);
|
||||
}
|
||||
request["images"] = json!(images);
|
||||
}
|
||||
if let Some(limit) = max_output_tokens {
|
||||
request["maxOutputTokens"] = json!(limit);
|
||||
}
|
||||
let turn = self
|
||||
.request_json(
|
||||
self.authed(
|
||||
self.client
|
||||
.post(format!("{}/v1/threads/{thread_id}/turns", self.base_url)),
|
||||
)
|
||||
.json(&request),
|
||||
)
|
||||
.await?;
|
||||
let turn_id = turn
|
||||
.pointer("/turn/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("runtime API turn response missing turn.id"))?
|
||||
.to_string();
|
||||
let response_id = format!("{thread_id}:{turn_id}");
|
||||
|
||||
if let Some(transcript) = transcript.as_deref_mut() {
|
||||
transcript.model = turn
|
||||
.pointer("/thread/model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
transcript.events.push(EventFrame::ResponseStart {
|
||||
response_id: response_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(error) = emit_stdio_event(
|
||||
writer,
|
||||
json!({
|
||||
"type": "response_start",
|
||||
"response_id": response_id,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let orphan = InFlightTurn {
|
||||
owner: TurnExecutionOwner::HistoricalHttp {
|
||||
base_url: self.base_url.clone(),
|
||||
auth_token: self.auth_token.clone(),
|
||||
},
|
||||
runtime_thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
connection: None,
|
||||
};
|
||||
if let Err(interrupt) = interrupt_turn_request(&orphan).await {
|
||||
tracing::warn!(
|
||||
thread_id,
|
||||
turn_id = %turn_id,
|
||||
"failed to interrupt a turn whose response_start could not be written: {}",
|
||||
interrupt.wire.message
|
||||
);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Publish the turn only for the streaming window, and take it back
|
||||
// before any `?` below: a turn that has already finished must never
|
||||
// look cancellable.
|
||||
if let Some((registry, key)) = registration.as_ref() {
|
||||
registry.lock().await.insert(
|
||||
key.clone(),
|
||||
InFlightTurn {
|
||||
owner: TurnExecutionOwner::HistoricalHttp {
|
||||
base_url: self.base_url.clone(),
|
||||
auth_token: self.auth_token.clone(),
|
||||
},
|
||||
runtime_thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
connection: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0);
|
||||
let stream_result = self
|
||||
.stream_turn_events(
|
||||
thread_id,
|
||||
&turn_id,
|
||||
&response_id,
|
||||
writer,
|
||||
since_seq,
|
||||
transcript.as_deref_mut(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some((registry, key)) = registration.as_ref() {
|
||||
registry.lock().await.remove(key);
|
||||
}
|
||||
|
||||
if stream_result.is_ok() {
|
||||
emit_stdio_event(
|
||||
writer,
|
||||
json!({
|
||||
"type": "response_end",
|
||||
"response_id": response_id,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
if let Some(transcript) = transcript {
|
||||
transcript.events.push(EventFrame::ResponseEnd {
|
||||
response_id: response_id.clone(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// The stream broke before `turn.completed` (transport error,
|
||||
// oversized or invalid frame, a writer that went away). Ending
|
||||
// the response here reported success ahead of the error, and the
|
||||
// runtime turn kept running with nothing left able to interrupt
|
||||
// it (the registry entry is gone), so the thread refused its next
|
||||
// message until the orphan finished. Stop it, best effort, and
|
||||
// let the error below be the only outcome the client sees.
|
||||
let orphan = InFlightTurn {
|
||||
owner: TurnExecutionOwner::HistoricalHttp {
|
||||
base_url: self.base_url.clone(),
|
||||
auth_token: self.auth_token.clone(),
|
||||
},
|
||||
runtime_thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
connection: None,
|
||||
};
|
||||
if let Err(error) = interrupt_turn_request(&orphan).await {
|
||||
tracing::warn!(
|
||||
thread_id,
|
||||
turn_id = %turn_id,
|
||||
"failed to interrupt a turn whose event stream broke: {}",
|
||||
error.wire.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (last_seq, status, error) = stream_result?;
|
||||
self.last_seq_by_thread
|
||||
.insert(thread_id.to_string(), last_seq);
|
||||
|
||||
match status {
|
||||
TurnTerminalStatus::Completed => Ok(json!({
|
||||
"thread_id": thread_id,
|
||||
"status": "accepted",
|
||||
"thread": Value::Null,
|
||||
"threads": [],
|
||||
"model": Value::Null,
|
||||
"model_provider": Value::Null,
|
||||
"cwd": Value::Null,
|
||||
"approval_policy": Value::Null,
|
||||
"sandbox": Value::Null,
|
||||
"events": [],
|
||||
"data": { "turn_id": turn_id },
|
||||
})),
|
||||
TurnTerminalStatus::Failed => Err(anyhow!(
|
||||
"{}",
|
||||
error.unwrap_or_else(|| "turn failed".to_string())
|
||||
)),
|
||||
TurnTerminalStatus::Interrupted => Err(anyhow!(
|
||||
"{}",
|
||||
error.unwrap_or_else(|| "turn interrupted".to_string())
|
||||
)),
|
||||
TurnTerminalStatus::Canceled => Err(anyhow!(
|
||||
"{}",
|
||||
error.unwrap_or_else(|| "turn canceled".to_string())
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn stream_turn_events<W: AsyncWrite + Unpin>(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
response_id: &str,
|
||||
writer: &mut W,
|
||||
since_seq: u64,
|
||||
mut transcript: Option<&mut TurnTranscript>,
|
||||
) -> Result<(u64, TurnTerminalStatus, Option<String>)> {
|
||||
let mut response = self
|
||||
.authed(self.client.get(format!(
|
||||
"{}/v1/threads/{thread_id}/events?since_seq={since_seq}",
|
||||
self.base_url
|
||||
)))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let mut last_seq = since_seq;
|
||||
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
buffer.extend_from_slice(&chunk);
|
||||
if buffer.len() > MAX_SSE_FRAME_BYTES {
|
||||
bail!(
|
||||
"runtime SSE frame exceeded {MAX_SSE_FRAME_BYTES} bytes without a frame delimiter"
|
||||
);
|
||||
}
|
||||
while let Some(frame_bytes) = take_sse_frame(&mut buffer) {
|
||||
let Some((event_name, frame_data)) = parse_sse_frame(&frame_bytes) else {
|
||||
continue;
|
||||
};
|
||||
let envelope: Value = serde_json::from_str(&frame_data)
|
||||
.with_context(|| format!("invalid SSE json for {event_name}: {frame_data}"))?;
|
||||
if let Some(seq) = envelope.get("seq").and_then(Value::as_u64) {
|
||||
last_seq = last_seq.max(seq);
|
||||
}
|
||||
if envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id) {
|
||||
continue;
|
||||
}
|
||||
let payload = envelope.get("payload").cloned().unwrap_or(Value::Null);
|
||||
match event_name.as_str() {
|
||||
"approval.required" | "user_input.required" => {
|
||||
bail!(
|
||||
"interactive_execution_unsupported: Runtime requested interaction during a tool-free compatibility turn; use codewhale app-server --http and /v1/*"
|
||||
);
|
||||
}
|
||||
"item.delta" => {
|
||||
let kind = payload
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if kind == "agent_message"
|
||||
&& let Some(delta) = payload.get("delta").and_then(Value::as_str)
|
||||
&& !delta.is_empty()
|
||||
{
|
||||
emit_stdio_event(
|
||||
writer,
|
||||
json!({
|
||||
"type": "response_delta",
|
||||
"response_id": response_id,
|
||||
"delta": delta,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
if let Some(transcript) = transcript.as_deref_mut() {
|
||||
transcript.text.push_str(delta);
|
||||
transcript.events.push(EventFrame::ResponseDelta {
|
||||
response_id: response_id.to_string(),
|
||||
delta: delta.to_string(),
|
||||
channel: ResponseChannel::Text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"turn.completed" => {
|
||||
let status = turn_terminal_status(&payload)?;
|
||||
let error = payload
|
||||
.pointer("/turn/error")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
return Ok((last_seq, status, error));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bail!("runtime event stream ended before turn.completed")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_base_url_for_test(base_url: String) -> Self {
|
||||
install_rustls_crypto_provider();
|
||||
Self {
|
||||
base_url,
|
||||
client: codewhale_release::platform_http_client_builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("build reqwest test client"),
|
||||
auth_token: None,
|
||||
child: None,
|
||||
last_seq_by_thread: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl RuntimeBridge {
|
||||
/// Whether the historical fixture child has exited. A bridge without a child (tests,
|
||||
/// or an externally managed runtime) never reports exited. A `try_wait`
|
||||
/// error counts as exited: with `WNOHANG` it only fails when the pid is no
|
||||
/// longer this process's child (already reaped elsewhere), and such a
|
||||
/// child can neither be tracked nor killed on drop.
|
||||
#[cfg(unix)]
|
||||
fn child_exited(&mut self) -> bool {
|
||||
self.child
|
||||
.as_mut()
|
||||
.is_some_and(|child| !matches!(child.try_wait(), Ok(None)))
|
||||
}
|
||||
|
||||
/// Kills the managed runtime child and reaps it on a detached thread so
|
||||
/// neither an explicit shutdown nor Drop blocks a Tokio runtime thread.
|
||||
fn shutdown_child(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RuntimeBridge {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown_child();
|
||||
}
|
||||
}
|
||||
|
||||
/// The line the Runtime prints once it holds its listener (the Runtime's
|
||||
/// `RUNTIME_LISTENING_PREFIX`; this crate does not depend on that one).
|
||||
#[cfg(test)]
|
||||
pub(crate) const RUNTIME_LISTENING_PREFIX: &str = "Runtime API listening on http://";
|
||||
|
||||
/// The endpoint line is short and comes first; anything larger is not it.
|
||||
#[cfg(test)]
|
||||
pub(crate) const RUNTIME_READY_MAX_BYTES: usize = 1024;
|
||||
|
||||
/// The endpoint a Runtime child reports for itself: exactly a nonzero port on
|
||||
/// `127.0.0.1`, the host the parent asked it to bind.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_runtime_endpoint(line: &str) -> Result<std::net::SocketAddr> {
|
||||
let address = line
|
||||
.trim_end()
|
||||
.strip_prefix(RUNTIME_LISTENING_PREFIX)
|
||||
.context("runtime API bridge did not report its endpoint")?;
|
||||
let endpoint: std::net::SocketAddr = address
|
||||
.parse()
|
||||
.context("runtime API bridge reported an invalid endpoint")?;
|
||||
if endpoint.ip() != std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) || endpoint.port() == 0
|
||||
{
|
||||
bail!("runtime API bridge reported an endpoint that is not a loopback port");
|
||||
}
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
/// Read the first stdout line, bounded.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn read_runtime_ready_line(stdout: &mut impl std::io::Read) -> Result<String> {
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
if stdout.read(&mut byte)? == 0 {
|
||||
bail!("runtime API bridge closed stdout before reporting its endpoint");
|
||||
}
|
||||
if byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
if line.len() >= RUNTIME_READY_MAX_BYTES {
|
||||
bail!("runtime API bridge sent an oversized readiness line");
|
||||
}
|
||||
line.push(byte[0]);
|
||||
}
|
||||
String::from_utf8(line).context("runtime API bridge readiness line is not UTF-8")
|
||||
}
|
||||
|
||||
pub(crate) fn install_rustls_crypto_provider() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_sse_frame(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
return Some(buffer.drain(..pos + 4).collect());
|
||||
}
|
||||
buffer
|
||||
.windows(2)
|
||||
.position(|window| window == b"\n\n")
|
||||
.map(|pos| buffer.drain(..pos + 2).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_sse_frame(frame_bytes: &[u8]) -> Option<(String, String)> {
|
||||
let text = String::from_utf8(frame_bytes.to_vec()).ok()?;
|
||||
let mut event_name = None;
|
||||
let mut data_lines = Vec::new();
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_end_matches('\r');
|
||||
if let Some(value) = line.strip_prefix("event:") {
|
||||
event_name = Some(value.trim().to_string());
|
||||
} else if let Some(value) = line.strip_prefix("data:") {
|
||||
data_lines.push(value.trim_start().to_string());
|
||||
}
|
||||
}
|
||||
match (event_name, data_lines.is_empty()) {
|
||||
(Some(event), false) => Some((event, data_lines.join("\n"))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,276 +0,0 @@
|
||||
#[cfg(test)]
|
||||
use crate::bridge::invalidate_runtime_bridge;
|
||||
use crate::http::ADVERTISED_ROUTES;
|
||||
use crate::owner_calls::runtime_app_models;
|
||||
use crate::{AppState, AppTransport};
|
||||
use anyhow::Context as _;
|
||||
#[cfg(not(test))]
|
||||
use anyhow::bail;
|
||||
use anyhow::{Result, anyhow};
|
||||
use codewhale_config::ConfigStore;
|
||||
use codewhale_protocol::{AppRequest, AppResponse};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) async fn process_app_request(
|
||||
state: &AppState,
|
||||
req: AppRequest,
|
||||
_transport: AppTransport,
|
||||
) -> AppResponse {
|
||||
match req {
|
||||
AppRequest::Capabilities => AppResponse {
|
||||
ok: true,
|
||||
data: json!({
|
||||
"routes": ADVERTISED_ROUTES,
|
||||
"interactive_execution": false,
|
||||
"goal_execution": false,
|
||||
"tool_free_turns": true,
|
||||
"full_api_entrypoint": "codewhale app-server --http",
|
||||
"config": ["get", "set", "unset", "list", "reload"],
|
||||
"events": ["response_start", "response_delta", "response_end"],
|
||||
"transport": "stdio+http",
|
||||
"config_path": state.config_path.as_ref().map(|p| p.display().to_string()),
|
||||
}),
|
||||
events: Vec::new(),
|
||||
},
|
||||
AppRequest::ConfigGet { key } => {
|
||||
let cfg = state.config.read().await;
|
||||
let value = cfg.get_display_value(&key);
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "key": key, "value": value }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigSet { key, value } => {
|
||||
// Only propagate a mutation that actually happened. `set_value`
|
||||
// leaves the config untouched on an unknown key or invalid value,
|
||||
// so this is a no-op from the caller's point of view — but
|
||||
// `propagate_config` invalidates the cached stdio bridge
|
||||
// regardless, and dropping the last reference kills the running
|
||||
// child runtime along with its thread map. A single typo'd key
|
||||
// would orphan every in-flight thread on that bridge.
|
||||
let result = {
|
||||
let (key, value) = (key.clone(), value.clone());
|
||||
persist_config_mutation(state, move |cfg| cfg.set_value(&key, &value)).await
|
||||
};
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
let value = if ok {
|
||||
value
|
||||
} else {
|
||||
codewhale_config::persistence::redact_secrets(&value)
|
||||
};
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "value": value, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigUnset { key } => {
|
||||
// See ConfigSet: a failed unset changed nothing and must not tear
|
||||
// down the runtime bridge.
|
||||
let result = {
|
||||
let key = key.clone();
|
||||
persist_config_mutation(state, move |cfg| cfg.unset_value(&key)).await
|
||||
};
|
||||
let ok = result.is_ok();
|
||||
let message = result.err().map(|e| e.to_string());
|
||||
AppResponse {
|
||||
ok,
|
||||
data: json!({ "key": key, "error": message }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigList => {
|
||||
let cfg = state.config.read().await;
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "values": cfg.list_values() }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::ConfigReload => {
|
||||
// Re-read both `config.toml` and the sibling `permissions.toml`
|
||||
// from disk (the headless equivalent of the TUI
|
||||
// `reload_runtime_config` codepath) and push the fresh
|
||||
// snapshots into `state.config` and the live `Runtime`.
|
||||
//
|
||||
// `ConfigStore::load` resolves the same default config path
|
||||
// that `build_state` used at startup when `config_path` is
|
||||
// `None`, so a `None` here reloads from the same on-disk file
|
||||
// the server booted from.
|
||||
// Disk is already the source of truth here, so nothing to
|
||||
// persist. External `permissions.toml` edits reach the Engine
|
||||
// because the update invalidates the runtime bridge; the next
|
||||
// turn's child loads both files fresh.
|
||||
if let Err(error) = update_config_store(state, |_| Ok(())).await {
|
||||
return AppResponse {
|
||||
ok: false,
|
||||
data: json!({ "error": error.to_string() }),
|
||||
events: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "reloaded": true }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
AppRequest::Models => {
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), runtime_app_models(state))
|
||||
.await
|
||||
.context("Runtime model catalog request timed out")
|
||||
.and_then(|result| result);
|
||||
match result {
|
||||
Ok(models) => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "models": models }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
Err(error) => AppResponse {
|
||||
ok: false,
|
||||
data: json!({ "error": error.to_string() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
AppRequest::ThreadLoadedList => {
|
||||
let response = crate::thread_control::loaded(state).await;
|
||||
match response {
|
||||
Ok(thread_resp) => AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "threads": thread_resp.threads }),
|
||||
events: thread_resp.events,
|
||||
},
|
||||
Err(err) => AppResponse {
|
||||
ok: false,
|
||||
data: json!({ "error": err.to_string() }),
|
||||
events: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
AppRequest::SubmitUserInput { request_id, .. } => {
|
||||
// This transport cannot deliver a clarification answer, and
|
||||
// saying otherwise was the bug: the previous implementation
|
||||
// reported `resolved: true` and filed the answers in a map with
|
||||
// no reader anywhere in this crate.
|
||||
//
|
||||
// It cannot be made to work here. `handle_line_during_turn`
|
||||
// executes exactly one method while a turn is streaming —
|
||||
// `thread/interrupt`. Everything else, `app/request` included,
|
||||
// queues until the turn ends, so an answer sent over this
|
||||
// transport would wait on the very turn that is waiting for it.
|
||||
// The runtime API owns the pending request and can resume the
|
||||
// turn, so that is where the reply belongs.
|
||||
AppResponse {
|
||||
ok: false,
|
||||
data: json!({
|
||||
"error": "user_input_reply_unsupported",
|
||||
"request_id": request_id,
|
||||
"message": concat!(
|
||||
"the app-server control transport cannot deliver clarification answers: ",
|
||||
"only `thread/interrupt` runs while a turn is streaming, so an answer sent ",
|
||||
"here would queue behind the turn waiting for it. Reply on the runtime API ",
|
||||
"instead: POST /v1/user-input/{thread_id}/{request_id}."
|
||||
),
|
||||
}),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the saved config in the bookkeeping Runtime, then apply it through
|
||||
/// the already captured canonical owner. Unbound production frontends refuse
|
||||
/// application while retaining the saved config for explicit recovery.
|
||||
/// A saved config whose owner reload fails is reported as retained but unapplied.
|
||||
pub(crate) async fn propagate_config(state: &AppState) -> Result<()> {
|
||||
{
|
||||
let mut runtime = state.runtime.write().await;
|
||||
let snapshot = state.config.read().await.clone();
|
||||
runtime.update_config(snapshot);
|
||||
}
|
||||
if state.captured_owner.is_some() {
|
||||
let owner = crate::owner_calls::captured(state)
|
||||
.context("config saved but captured Runtime owner is unavailable")?;
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
crate::owner_calls::execute(&owner, crate::RuntimeOwnerRequest::ReloadConfig),
|
||||
)
|
||||
.await
|
||||
.context("config saved; captured owner reload outcome is uncertain, not replayed")?
|
||||
.context("config saved but captured owner rejected reload")?;
|
||||
} else {
|
||||
#[cfg(test)]
|
||||
invalidate_runtime_bridge(state).await;
|
||||
#[cfg(not(test))]
|
||||
bail!("config saved but no captured Runtime owner can apply it");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prefix of a config error that is the server's fault (the file could not
|
||||
/// be read, parsed, or written), reported over HTTP `/app` as a 500 rather
|
||||
/// than the 400 a rejected key or value gets.
|
||||
pub(crate) const CONFIG_LOAD_ERROR: &str = "failed to load config";
|
||||
|
||||
/// See [`CONFIG_LOAD_ERROR`].
|
||||
pub(crate) const CONFIG_SAVE_ERROR: &str = "failed to save config";
|
||||
|
||||
/// Apply `mutate` to the config on disk, then propagate the saved result.
|
||||
///
|
||||
/// The mutation runs against a freshly loaded store, not the in-memory
|
||||
/// snapshot, so edits another process (TUI, `codewhale login`) saved since
|
||||
/// startup survive. With no explicit `--config` the store resolves the same
|
||||
/// default path the runtime child reads, so the change reaches turns and
|
||||
/// survives a restart. Any load, mutation, or save failure is returned and
|
||||
/// nothing is propagated, so the caller never reports `ok` for a change
|
||||
/// that was not kept.
|
||||
///
|
||||
/// The work runs on its own task: once the file is written, a caller that
|
||||
/// goes away (an HTTP client disconnect) must not leave disk ahead of
|
||||
/// `state.config`, the live runtime, and the cached bridge.
|
||||
pub(crate) async fn persist_config_mutation(
|
||||
state: &AppState,
|
||||
mutate: impl FnOnce(&mut codewhale_config::ConfigToml) -> Result<()> + Send + 'static,
|
||||
) -> Result<()> {
|
||||
update_config_store(state, move |store| {
|
||||
mutate(&mut store.config)?;
|
||||
store
|
||||
.save()
|
||||
.map_err(|err| anyhow!("{CONFIG_SAVE_ERROR}: {err}"))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Serialize reloads and writes before reading disk, and finish propagation
|
||||
/// even if the requesting connection disappears. A reload mutates nothing.
|
||||
pub(crate) async fn update_config_store(
|
||||
state: &AppState,
|
||||
update: impl FnOnce(&mut ConfigStore) -> Result<()> + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
// Own the write guard across load→mutate→save→install so two
|
||||
// concurrent mutations cannot overwrite one another. All disk work
|
||||
// runs off-runtime; the owned operation still finishes propagation
|
||||
// when the requesting connection goes away.
|
||||
let mut config = state.config.clone().write_owned().await;
|
||||
let config_path = state.config_path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let mut store = ConfigStore::load(config_path)
|
||||
.map_err(|err| anyhow!("{CONFIG_LOAD_ERROR}: {err}"))?;
|
||||
update(&mut store)?;
|
||||
*config = store.config;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow!("config store task failed: {err}"))??;
|
||||
propagate_config(&state).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow!("config update task failed: {err}"))?
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
use crate::{DispatchError, params_or_object, parse_params};
|
||||
use codewhale_app_server_protocol::lifecycle::{InitializeParams, InitializeResponse};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct ConnectionId(u64);
|
||||
|
||||
impl ConnectionId {
|
||||
fn mint() -> Self {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
Self(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ConnectionSession {
|
||||
id: ConnectionId,
|
||||
initialization: Option<InitializeParams>,
|
||||
}
|
||||
|
||||
impl Default for ConnectionSession {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: ConnectionId::mint(),
|
||||
initialization: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectionSession {
|
||||
pub(crate) fn id(&self) -> ConnectionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub(crate) fn initialized(&self) -> bool {
|
||||
self.initialization.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn initialize(&mut self, params: Value) -> Result<Value, DispatchError> {
|
||||
if self.initialized() {
|
||||
return Err(DispatchError::invalid_request("Already initialized"));
|
||||
}
|
||||
let params: InitializeParams = parse_params(params_or_object(params))?;
|
||||
axum::http::HeaderValue::from_str(¶ms.client_info.name)
|
||||
.map_err(|_| DispatchError::invalid_request("Invalid clientInfo.name"))?;
|
||||
if let Some(capabilities) = params.capabilities.as_ref()
|
||||
&& (capabilities.explicit_gateway_oauth
|
||||
|| capabilities.request_attestation
|
||||
|| capabilities.mcp_server_openai_form_elicitation
|
||||
|| capabilities
|
||||
.opt_out_notification_methods
|
||||
.as_ref()
|
||||
.is_some_and(|methods| !methods.is_empty())
|
||||
|| capabilities
|
||||
.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|extensions| !extensions.is_empty()))
|
||||
{
|
||||
return Err(DispatchError::invalid_request(
|
||||
"Requested initialization capability is not implemented",
|
||||
));
|
||||
}
|
||||
let home = codewhale_paths::codewhale_home()
|
||||
.map_err(|error| DispatchError::internal(error.to_string()))?
|
||||
.filter(|path| path.is_absolute())
|
||||
.ok_or_else(|| DispatchError::internal("absolute Codewhale home unavailable"))?;
|
||||
let response = InitializeResponse {
|
||||
user_agent: format!(
|
||||
"Codewhale/{} ({}; {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
params.client_info.name,
|
||||
params.client_info.version
|
||||
),
|
||||
codex_home: home,
|
||||
platform_family: std::env::consts::FAMILY.to_string(),
|
||||
platform_os: std::env::consts::OS.to_string(),
|
||||
};
|
||||
let value = serde_json::to_value(response)
|
||||
.map_err(|error| DispatchError::internal(error.to_string()))?;
|
||||
self.initialization = Some(params);
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,15 @@ use std::path::PathBuf;
|
||||
mod platform {
|
||||
use super::*;
|
||||
use crate::daemon_socket::AuthorizedPeer;
|
||||
#[cfg(windows)]
|
||||
use crate::daemon_socket::default_socket_path;
|
||||
use crate::stdio::{
|
||||
BoundedLines, ParsedStdioLine, is_frame_refusal, parse_stdio_line, serialize_control_frame,
|
||||
write_control_line, write_stdio_line,
|
||||
};
|
||||
use crate::daemon_socket::{default_socket_path, owner_work};
|
||||
use crate::{BoundedLines, ParsedStdioLine, parse_stdio_line, write_stdio_line};
|
||||
use anyhow::{Context as _, bail};
|
||||
use codewhale_app_server_protocol::lifecycle::InitializeResponse;
|
||||
use codewhale_app_server_protocol::rpc::{
|
||||
RequestId, RpcCall, RpcErrorResponse, RpcNotification, RpcResponse,
|
||||
};
|
||||
use codewhale_config::private_directory::PrivateDirectory;
|
||||
#[cfg(unix)]
|
||||
use codewhale_paths::daemon_socket::default_socket_path;
|
||||
use codewhale_protocol::RuntimeOwnerReceipt;
|
||||
use codewhale_runtime::runtime_control::owner_work;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::io::{AsyncWriteExt as _, BufReader};
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixStream;
|
||||
#[cfg(unix)]
|
||||
@@ -36,47 +25,13 @@ mod platform {
|
||||
#[cfg(windows)]
|
||||
type Connection = tokio::net::windows::named_pipe::NamedPipeClient;
|
||||
|
||||
type OwnerTransport = crate::websocket::LocalWebSocket<
|
||||
tokio::io::ReadHalf<Connection>,
|
||||
tokio::io::WriteHalf<Connection>,
|
||||
>;
|
||||
|
||||
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
|
||||
if left == right {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if left.file_name().is_none() || left.file_name() != right.file_name() {
|
||||
return false;
|
||||
}
|
||||
match (
|
||||
left.parent()
|
||||
.map(codewhale_paths::resolve_root_owned_ancestors),
|
||||
right
|
||||
.parent()
|
||||
.map(codewhale_paths::resolve_root_owned_ancestors),
|
||||
) {
|
||||
(Some(left), Some(right)) => left == right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OwnerClient {
|
||||
read: BoundedLines<BufReader<tokio::io::ReadHalf<OwnerTransport>>>,
|
||||
write: tokio::io::WriteHalf<OwnerTransport>,
|
||||
read: BoundedLines<BufReader<tokio::io::ReadHalf<Connection>>>,
|
||||
write: tokio::io::WriteHalf<Connection>,
|
||||
peer: Arc<AuthorizedPeer>,
|
||||
receipt: RuntimeOwnerReceipt,
|
||||
routing: Option<crate::RuntimeOwnerRouting>,
|
||||
frontend: crate::daemon_socket::AttachFrontend,
|
||||
output_failed: bool,
|
||||
native_initialized: bool,
|
||||
pending_initialize: Option<RequestId>,
|
||||
}
|
||||
|
||||
/// Host startup is the only caller allowed to acquire the Runtime lease.
|
||||
@@ -220,7 +175,7 @@ mod platform {
|
||||
old.version == owner.version
|
||||
&& old.data_dir == owner.data_dir
|
||||
&& old.execution_scope == owner.execution_scope
|
||||
&& same_socket_selection(&old.socket_path, endpoint)
|
||||
&& old.socket_path == endpoint
|
||||
&& old.config_path == owner.config_path
|
||||
&& old.principal == owner.principal,
|
||||
"captured stale owner belongs to another selected store/config/principal"
|
||||
@@ -275,15 +230,7 @@ mod platform {
|
||||
#[cfg(windows)]
|
||||
let name = "daemon.owner.json".to_string();
|
||||
owner_work(move || {
|
||||
// This runs before the engine has anywhere to log, so a refusal
|
||||
// here is the first thing a user sees and the last thing they can
|
||||
// diagnose. A mount that reports its own ownership (HarmonyOS
|
||||
// hmdfs) makes the strict proof impossible for every object on it,
|
||||
// so discovering an owner — not authenticating one — takes the
|
||||
// measured exception. The receipt is still read through the
|
||||
// retained handle, must be a single-link file, and the socket
|
||||
// identity is still compared.
|
||||
let parent = match PrivateDirectory::inspect_trusting_mount(&directory) {
|
||||
let parent = match PrivateDirectory::inspect(&directory) {
|
||||
Ok(parent) => Arc::new(parent),
|
||||
Err(error)
|
||||
if error
|
||||
@@ -304,7 +251,7 @@ mod platform {
|
||||
anyhow::ensure!(
|
||||
receipt.version == 1
|
||||
&& receipt.pid > 0
|
||||
&& same_socket_selection(&receipt.socket_path, &socket_path)
|
||||
&& receipt.socket_path == socket_path
|
||||
&& !receipt.lease_generation.is_empty(),
|
||||
"selected owner receipt is invalid"
|
||||
);
|
||||
@@ -599,20 +546,13 @@ mod platform {
|
||||
.is_none_or(|expected| expected == &receipt),
|
||||
"selected owner changed before frontend admission"
|
||||
);
|
||||
let (read, write) = tokio::io::split(stream);
|
||||
let transport = crate::websocket::connect(read, write).await?;
|
||||
let (read, mut write) = tokio::io::split(transport);
|
||||
let (read, mut write) = tokio::io::split(stream);
|
||||
let mut replies = BoundedLines::new(BufReader::new(read));
|
||||
let attach_id = uuid::Uuid::new_v4().to_string();
|
||||
let attach = json!(RpcCall {
|
||||
id: Some(RequestId::String(attach_id.clone())),
|
||||
method: "daemon/attach".into(),
|
||||
params: json!({
|
||||
"client":{"name":"codewhale-stdio","version":env!("CARGO_PKG_VERSION"),"pid":std::process::id()},
|
||||
"mode":"attach","frontend":frontend,"listener":listener,"scope":scope,"acp_model":acp_model,"expect_daemon_version":env!("CARGO_PKG_VERSION"),"expect_owner":receipt
|
||||
}),
|
||||
trace: None
|
||||
});
|
||||
let attach = json!({"jsonrpc":"2.0","id":attach_id,"method":"daemon/attach","params":{
|
||||
"client":{"name":"codewhale-stdio","version":env!("CARGO_PKG_VERSION"),"pid":std::process::id()},
|
||||
"mode":"attach","frontend":frontend,"listener":listener,"scope":scope,"acp_model":acp_model,"expect_daemon_version":env!("CARGO_PKG_VERSION"),"expect_owner":receipt
|
||||
}});
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
write_stdio_line(&mut write, &attach),
|
||||
@@ -650,9 +590,6 @@ mod platform {
|
||||
receipt,
|
||||
routing,
|
||||
frontend,
|
||||
output_failed: false,
|
||||
native_initialized: false,
|
||||
pending_initialize: None,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -665,159 +602,60 @@ mod platform {
|
||||
&self.receipt
|
||||
}
|
||||
pub async fn send(&mut self, id: Value, method: &str, params: Value) -> Result<()> {
|
||||
let frame = json!({"jsonrpc":"2.0","id":id,"method":method,"params":params});
|
||||
let bytes = serde_json::to_vec(&frame)?;
|
||||
anyhow::ensure!(
|
||||
!self.output_failed,
|
||||
"owner transport write previously failed; outcomes uncertain, not replayed"
|
||||
bytes.len() <= crate::MAX_RUNTIME_IMAGE_BODY_BYTES,
|
||||
"owner request exceeds transport bound"
|
||||
);
|
||||
if self.frontend == crate::daemon_socket::AttachFrontend::Acp {
|
||||
return self
|
||||
.send_frame(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
|
||||
.await;
|
||||
}
|
||||
let id: RequestId = serde_json::from_value(id)?;
|
||||
anyhow::ensure!(
|
||||
self.pending_initialize.is_none(),
|
||||
"receive the pending initialize acknowledgement before sending another request"
|
||||
);
|
||||
if self.frontend == crate::daemon_socket::AttachFrontend::Control
|
||||
&& !self.native_initialized
|
||||
&& method != "initialize"
|
||||
{
|
||||
let initialize_id = RequestId::String(format!(
|
||||
"codewhale-native-initialize-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
self.send_call(RpcCall { id: Some(initialize_id.clone()), method: "initialize".into(), params: json!({"clientInfo":{"name":"codewhale-native-control","version":env!("CARGO_PKG_VERSION")}}), trace: None }).await?;
|
||||
self.pending_initialize = Some(initialize_id.clone());
|
||||
let acknowledged: Result<()> = async {
|
||||
let response = tokio::time::timeout(Duration::from_secs(5), self.recv()).await.context("owner initialize response deadline expired; connection outcome uncertain")??.context("owner input closed before initialize acknowledgement")?;
|
||||
anyhow::ensure!(response.get("id") == Some(&json!(initialize_id)) && response.get("error").is_none() && response.get("result").is_some(), "owner initialize acknowledgement mismatch or refusal");
|
||||
Ok(())
|
||||
}.await;
|
||||
if let Err(error) = acknowledged {
|
||||
self.output_failed = true;
|
||||
return Err(error);
|
||||
}
|
||||
self.native_initialized = true;
|
||||
}
|
||||
let pending = (self.frontend == crate::daemon_socket::AttachFrontend::Control
|
||||
&& method == "initialize"
|
||||
&& !self.native_initialized)
|
||||
.then(|| id.clone());
|
||||
self.send_call(RpcCall {
|
||||
id: Some(id),
|
||||
method: method.to_string(),
|
||||
params,
|
||||
trace: None,
|
||||
})
|
||||
.await?;
|
||||
self.pending_initialize = pending;
|
||||
Ok(())
|
||||
}
|
||||
async fn send_call(&mut self, request: RpcCall) -> Result<()> {
|
||||
self.send_frame(json!(request)).await
|
||||
}
|
||||
async fn send_frame(&mut self, frame: Value) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!self.output_failed,
|
||||
"owner transport write previously failed; outcomes uncertain, not replayed"
|
||||
);
|
||||
let bytes = serialize_control_frame(&frame)?;
|
||||
let check = self.peer.clone();
|
||||
owner_work(move || check.check()).await?;
|
||||
self.output_failed = true;
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
write_control_line(&mut self.write, &bytes),
|
||||
write_stdio_line(&mut self.write, &frame),
|
||||
)
|
||||
.await
|
||||
.context("owner write deadline expired; outcome uncertain, not replayed")??;
|
||||
self.output_failed = false;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn recv(&mut self) -> Result<Option<Value>> {
|
||||
anyhow::ensure!(
|
||||
!self.output_failed,
|
||||
"owner transport write previously failed; outcomes uncertain, not replayed"
|
||||
);
|
||||
let Some(line) = self.read.next_line().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let check = self.peer.clone();
|
||||
owner_work(move || check.check()).await?;
|
||||
let value: Value = serde_json::from_str(&line)?;
|
||||
if let Some(id) = self.pending_initialize.as_ref()
|
||||
&& value.get("id") == Some(&json!(id))
|
||||
{
|
||||
let response = serde_json::from_value::<RpcResponse>(value.clone());
|
||||
let accepted = response
|
||||
.ok()
|
||||
.filter(|_| value.get("error").is_none())
|
||||
.and_then(|response| {
|
||||
serde_json::from_value::<InitializeResponse>(response.result).ok()
|
||||
})
|
||||
.is_some_and(|response| response.codex_home.is_absolute());
|
||||
let rejected = serde_json::from_value::<RpcErrorResponse>(value.clone()).is_ok();
|
||||
if !accepted && !rejected {
|
||||
self.output_failed = true;
|
||||
anyhow::bail!("initialize acknowledgement does not match its typed response");
|
||||
}
|
||||
self.native_initialized = accepted;
|
||||
self.pending_initialize = None;
|
||||
if accepted {
|
||||
self.send_call(RpcCall {
|
||||
id: None,
|
||||
method: "initialized".into(),
|
||||
params: Value::Null,
|
||||
trace: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(Some(value))
|
||||
serde_json::from_str(&line).map(Some).map_err(Into::into)
|
||||
}
|
||||
pub async fn forward<I, O>(mut self, input: I, output: O) -> Result<()>
|
||||
where
|
||||
I: tokio::io::AsyncRead + Unpin,
|
||||
O: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
anyhow::ensure!(
|
||||
!self.output_failed,
|
||||
"owner transport write previously failed; outcomes uncertain, not replayed"
|
||||
);
|
||||
let mut input = BoundedLines::new(BufReader::new(input));
|
||||
let mut output = tokio::io::BufWriter::new(output);
|
||||
let mut input_open = true;
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = input.next_line(), if input_open => match match line {
|
||||
Ok(line) => line,
|
||||
Err(error) if self.frontend == crate::daemon_socket::AttachFrontend::Acp && is_frame_refusal(&error) => {
|
||||
write_stdio_line(&mut output, &json!({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":error.to_string()}})).await?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
} {
|
||||
line = input.next_line(), if input_open => match line? {
|
||||
None => {
|
||||
input_open=false;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
write_stdio_line(&mut self.write,&json!(RpcNotification { method:"daemon/input_closed".into(), params:Some(json!({})) })).await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}).await.context("owner input-close write outcome uncertain, not replayed")??;
|
||||
tokio::time::timeout(Duration::from_secs(5),write_stdio_line(&mut self.write,&json!({"jsonrpc":"2.0","method":"daemon/input_closed","params":{}}))).await.context("owner input-close write outcome uncertain, not replayed")??;
|
||||
self.write.shutdown().await?;
|
||||
}
|
||||
Some(line) if self.frontend==crate::daemon_socket::AttachFrontend::Acp => {
|
||||
let check=self.peer.clone();
|
||||
owner_work(move || check.check()).await?;
|
||||
let value:Value=serde_json::from_str(&line)?;
|
||||
anyhow::ensure!(value.is_object() && value["jsonrpc"]=="2.0","ACP frame must be a JSON-RPC object");
|
||||
// Preserve response IDs/permission replies verbatim.
|
||||
// Actual ACP authority/parser stays in AcpServer.
|
||||
tokio::time::timeout(Duration::from_secs(5),write_control_line(&mut self.write,line.as_bytes())).await.context("ACP write outcome uncertain; not replayed")??;
|
||||
tokio::time::timeout(Duration::from_secs(5),async {self.write.write_all(line.as_bytes()).await?;self.write.write_all(b"\n").await?;self.write.flush().await}).await.context("ACP write outcome uncertain; not replayed")??;
|
||||
}
|
||||
Some(line) => match parse_stdio_line(&line) {
|
||||
ParsedStdioLine::Blank => {},
|
||||
ParsedStdioLine::Ignored => {},
|
||||
ParsedStdioLine::Response(response) => self.send_frame(response).await?,
|
||||
ParsedStdioLine::Rejected(response) => write_stdio_line(&mut output, &response).await?,
|
||||
ParsedStdioLine::Request(request) => {
|
||||
self.send_call(request).await?;
|
||||
self.send(request.id.unwrap_or(Value::Null),&request.method,request.params).await?;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -827,7 +665,9 @@ mod platform {
|
||||
None => bail!("selected owner connection closed; pending operation outcomes may be uncertain"),
|
||||
Some(line) => {
|
||||
let _: Value = serde_json::from_str(&line).context("invalid owner response")?;
|
||||
write_control_line(&mut output, line.as_bytes()).await?;
|
||||
output.write_all(line.as_bytes()).await?;
|
||||
output.write_all(b"\n").await?;
|
||||
output.flush().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,6 +675,12 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forward_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
connect(config_path, None)
|
||||
.await?
|
||||
.forward(tokio::io::stdin(), tokio::io::stdout())
|
||||
.await
|
||||
}
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -861,6 +707,11 @@ mod platform {
|
||||
pub use platform::{
|
||||
HostOwnerProbe, OwnerClient, PublicationChangedBeforeAttach, UnavailablePublication, connect,
|
||||
connect_acp_if_published, connect_if_published, connect_listener_if_published,
|
||||
connect_scoped_control_if_published, connect_selected_acp_if_published,
|
||||
connect_scoped_control_if_published, connect_selected_acp_if_published, forward_stdio,
|
||||
probe_owner_for_host_startup, publication_is_absent,
|
||||
};
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub async fn forward_stdio(_config_path: Option<PathBuf>) -> Result<()> {
|
||||
anyhow::bail!("authenticated live-owner control is not implemented on this platform")
|
||||
}
|
||||
@@ -3,10 +3,22 @@
|
||||
//! The desktop shell attaches to a long-lived `codewhale app-server --socket`
|
||||
//! daemon over a local socket instead of a TCP port: local multi-client,
|
||||
//! peer-credential auth, nothing to firewall (CORE-PROTOCOL spec §5). The
|
||||
//! wire carries RPC objects in WebSocket text messages; process stdio uses
|
||||
//! JSONL. Both use `crate::stdio::run_stdio_loop` after the private channel's
|
||||
//! WebSocket upgrade and `daemon/attach` handshake establish the connection
|
||||
//! and whether this client owns the daemon.
|
||||
//! wire is *identical* to the `--stdio` transport — newline-delimited
|
||||
//! JSON-RPC 2.0 driven by the same `crate::run_stdio_loop` — with exactly
|
||||
//! one addition in front of it: a `daemon/attach` handshake that establishes
|
||||
//! who this client is and whether it owns the daemon.
|
||||
//!
|
||||
//! # Endpoint resolution
|
||||
//!
|
||||
//! In precedence order (see [`resolve_socket_path`]):
|
||||
//!
|
||||
//! 1. an explicit path (`--socket-path`);
|
||||
//! 2. `$CODEWHALE_HOME/run/daemon.sock` when `CODEWHALE_HOME` is set — an
|
||||
//! explicit home is an isolation boundary, so its daemon must not collide
|
||||
//! with the default one;
|
||||
//! 3. `$XDG_RUNTIME_DIR/codewhale/daemon.sock`;
|
||||
//! 4. macOS: `~/Library/Application Support/codewhale/daemon.sock`;
|
||||
//! 5. `~/.codewhale/run/daemon.sock`.
|
||||
//!
|
||||
//! Windows derives a local named-pipe endpoint from the selected home and
|
||||
//! current principal. It shares the same dispatcher and ownership handshake;
|
||||
@@ -21,21 +33,84 @@
|
||||
//! disconnects the slot frees, so a relaunched shell can re-claim the daemon
|
||||
//! it left running — sessions survive UI restarts because the daemon does.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use codewhale_protocol::RuntimeOwnerReceipt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use codewhale_paths::daemon_socket::{
|
||||
DAEMON_SOCKET_FILE_NAME, DaemonSocketError, MAX_SOCKET_PATH_BYTES, SocketPathInputs,
|
||||
WINDOWS_NAMED_PIPE, resolve_socket_path,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
pub use codewhale_paths::daemon_socket::{default_socket_path, store_selected_socket_path};
|
||||
/// Basename of the daemon socket inside the Codewhale runtime directory.
|
||||
pub const DAEMON_SOCKET_FILE_NAME: &str = "daemon.sock";
|
||||
|
||||
/// Historical reserved endpoint used in unsupported-platform diagnostics.
|
||||
/// Actual Windows owner endpoints are scoped to the selected home/principal.
|
||||
pub const WINDOWS_NAMED_PIPE: &str = r"\\.\pipe\codewhale-daemon";
|
||||
|
||||
/// JSON-RPC method a client must send first on a daemon-socket connection.
|
||||
pub const ATTACH_METHOD: &str = "daemon/attach";
|
||||
|
||||
/// Longest socket path the kernel accepts (`sun_path` minus the NUL).
|
||||
pub const MAX_SOCKET_PATH_BYTES: usize = if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
103
|
||||
} else {
|
||||
107
|
||||
};
|
||||
|
||||
/// Typed failures of the daemon socket transport.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DaemonSocketError {
|
||||
/// The platform has no daemon socket implementation. Never a silent
|
||||
/// fallback: the caller must pick another transport explicitly.
|
||||
#[error(
|
||||
"the daemon socket transport is not supported on {platform}; the reserved endpoint \
|
||||
there is the named pipe {planned_endpoint}, which is not implemented yet"
|
||||
)]
|
||||
UnsupportedPlatform {
|
||||
platform: &'static str,
|
||||
planned_endpoint: &'static str,
|
||||
},
|
||||
/// No home directory (or runtime directory) to derive a default path from.
|
||||
#[error(
|
||||
"cannot resolve the Codewhale runtime directory for the daemon socket: no home directory"
|
||||
)]
|
||||
RuntimeDirUnavailable,
|
||||
/// `CODEWHALE_HOME` is set but not a usable absolute path.
|
||||
#[error("invalid CODEWHALE_HOME override: {0}")]
|
||||
InvalidHomeOverride(String),
|
||||
/// Unix socket paths are limited to roughly one hundred bytes.
|
||||
#[error("daemon socket path {} is {len} bytes; this platform allows at most {max}", path.display())]
|
||||
PathTooLong {
|
||||
path: PathBuf,
|
||||
len: usize,
|
||||
max: usize,
|
||||
},
|
||||
/// Something other than a socket already sits at the path. Refused so a
|
||||
/// misconfigured path can never delete a user's file.
|
||||
#[error("{} exists and is not a unix socket; refusing to remove it", path.display())]
|
||||
NotASocket { path: PathBuf },
|
||||
/// A daemon answered on the socket: this one must not replace it.
|
||||
#[error(
|
||||
"a live listener already answers on {}; refusing to replace it (another codewhale daemon, or something else bound to this path)",
|
||||
path.display()
|
||||
)]
|
||||
AlreadyRunning { path: PathBuf },
|
||||
/// The liveness probe neither connected nor was refused within the
|
||||
/// budget. Refused rather than clobbered; remove the file by hand if the
|
||||
/// old daemon is truly gone.
|
||||
#[error("liveness probe of {} timed out; refusing to replace a socket that may be live", path.display())]
|
||||
ProbeTimedOut { path: PathBuf },
|
||||
/// Filesystem or socket I/O failed.
|
||||
#[error("{context} ({})", path.display())]
|
||||
Io {
|
||||
context: &'static str,
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// The app-server state (config, state store, runtime) failed to build.
|
||||
#[error("failed to build daemon state")]
|
||||
State(#[source] anyhow::Error),
|
||||
}
|
||||
|
||||
/// How to start the daemon socket transport.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DaemonSocketOptions {
|
||||
@@ -45,6 +120,145 @@ pub struct DaemonSocketOptions {
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Inputs to [`resolve_socket_path`], separated from the environment so the
|
||||
/// precedence rules are a pure, testable function.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SocketPathInputs {
|
||||
/// `--socket-path`.
|
||||
pub explicit: Option<PathBuf>,
|
||||
/// A valid explicit `CODEWHALE_HOME`.
|
||||
pub codewhale_home_override: Option<PathBuf>,
|
||||
/// `$XDG_RUNTIME_DIR`, when set and non-empty.
|
||||
pub xdg_runtime_dir: Option<PathBuf>,
|
||||
/// The user's home directory.
|
||||
pub user_home: Option<PathBuf>,
|
||||
/// Whether the macOS Application Support layout applies.
|
||||
pub macos: bool,
|
||||
}
|
||||
|
||||
impl SocketPathInputs {
|
||||
/// Capture the live environment.
|
||||
pub fn from_environment(explicit: Option<PathBuf>) -> Result<Self, DaemonSocketError> {
|
||||
let codewhale_home_override = codewhale_paths::codewhale_home_override()
|
||||
.map_err(|err| DaemonSocketError::InvalidHomeOverride(err.to_string()))?;
|
||||
let xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
Ok(Self {
|
||||
explicit,
|
||||
codewhale_home_override,
|
||||
xdg_runtime_dir,
|
||||
user_home: codewhale_paths::user_home(),
|
||||
macos: cfg!(target_os = "macos"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the precedence rules documented at the module level and enforce the
|
||||
/// kernel's path-length limit.
|
||||
pub fn resolve_socket_path(inputs: &SocketPathInputs) -> Result<PathBuf, DaemonSocketError> {
|
||||
let path = if let Some(explicit) = inputs.explicit.clone() {
|
||||
explicit
|
||||
} else if let Some(home) = inputs.codewhale_home_override.clone() {
|
||||
home.join("run").join(DAEMON_SOCKET_FILE_NAME)
|
||||
} else if let Some(runtime_dir) = inputs.xdg_runtime_dir.clone() {
|
||||
runtime_dir.join("codewhale").join(DAEMON_SOCKET_FILE_NAME)
|
||||
} else {
|
||||
let user_home = inputs
|
||||
.user_home
|
||||
.clone()
|
||||
.ok_or(DaemonSocketError::RuntimeDirUnavailable)?;
|
||||
if inputs.macos {
|
||||
user_home
|
||||
.join("Library")
|
||||
.join("Application Support")
|
||||
.join("codewhale")
|
||||
.join(DAEMON_SOCKET_FILE_NAME)
|
||||
} else {
|
||||
user_home
|
||||
.join(codewhale_paths::CODEWHALE_APP_DIR)
|
||||
.join("run")
|
||||
.join(DAEMON_SOCKET_FILE_NAME)
|
||||
}
|
||||
};
|
||||
let len = path.as_os_str().len();
|
||||
if len > MAX_SOCKET_PATH_BYTES {
|
||||
return Err(DaemonSocketError::PathTooLong {
|
||||
path,
|
||||
len,
|
||||
max: MAX_SOCKET_PATH_BYTES,
|
||||
});
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The socket path this host would use with no explicit override.
|
||||
#[cfg(unix)]
|
||||
pub fn default_socket_path() -> Result<PathBuf, DaemonSocketError> {
|
||||
resolve_socket_path(&SocketPathInputs::from_environment(None)?)
|
||||
}
|
||||
|
||||
/// Digest bytes that name a derived socket. Six bytes (twelve hex characters,
|
||||
/// the same digest length the runtime-boundary id uses) make the basename a
|
||||
/// fixed 23 bytes with the `store-` prefix and the `.sock` suffix, which keeps
|
||||
/// the hard kernel path limit reachable only for an already-close default
|
||||
/// endpoint instead of consuming the remaining headroom.
|
||||
#[cfg(unix)]
|
||||
const DERIVED_STORE_HASH_BYTES: usize = 6;
|
||||
|
||||
/// The control endpoint for a selected runtime store: the well-known default
|
||||
/// socket when the store is the default selection, and a private sibling
|
||||
/// derived from the store root otherwise.
|
||||
///
|
||||
/// One control endpoint per runtime store, as a pure function of the store
|
||||
/// selection. A client that selects a non-default store (`CODEWHALE_RUNTIME_DIR`,
|
||||
/// e.g. the VS Code extension's per-workspace store) elects its own owner
|
||||
/// instead of colliding with the default store's owner on the shared socket;
|
||||
/// the default store keeps the historical path, so discovery, `doctor` and
|
||||
/// single-engine setups are unchanged.
|
||||
///
|
||||
/// `store_root` is the canonicalized runtime store root the caller selected;
|
||||
/// `None` means "the default selection" (no override in effect).
|
||||
///
|
||||
/// Unix only: a Windows owner endpoint is a named pipe, and its derived-name
|
||||
/// form is follow-up work. Windows keeps the well-known endpoint it has today.
|
||||
#[cfg(unix)]
|
||||
pub fn store_selected_socket_path(
|
||||
store_root: Option<&Path>,
|
||||
inputs: &SocketPathInputs,
|
||||
) -> Result<PathBuf, DaemonSocketError> {
|
||||
use anyhow::Context as _;
|
||||
use sha2::Digest as _;
|
||||
|
||||
let base = resolve_socket_path(inputs)?;
|
||||
let Some(store_root) = store_root else {
|
||||
return Ok(base);
|
||||
};
|
||||
let mut digest = sha2::Sha256::new();
|
||||
// The tag keeps this name from colliding with any other use of the store
|
||||
// root's digest in the same directory.
|
||||
digest.update(b"Codewhale/store-socket/v1\0");
|
||||
digest.update(store_root.as_os_str().as_encoded_bytes());
|
||||
let suffix: String = digest.finalize()[..DERIVED_STORE_HASH_BYTES]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let parent = base
|
||||
.parent()
|
||||
.context("default daemon socket has no parent directory")
|
||||
.map_err(DaemonSocketError::State)?;
|
||||
let derived = parent.join(format!("store-{suffix}.sock"));
|
||||
let len = derived.as_os_str().len();
|
||||
if len > MAX_SOCKET_PATH_BYTES {
|
||||
return Err(DaemonSocketError::PathTooLong {
|
||||
path: derived,
|
||||
len,
|
||||
max: MAX_SOCKET_PATH_BYTES,
|
||||
});
|
||||
}
|
||||
Ok(derived)
|
||||
}
|
||||
|
||||
/// The socket path this host would use with no explicit override.
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub fn default_socket_path() -> Result<PathBuf, DaemonSocketError> {
|
||||
@@ -122,8 +336,28 @@ fn unsupported_platform() -> DaemonSocketError {
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded blocking boundary for held filesystem/process identity work.
|
||||
/// The worker retains its permit and captured handles if the waiter cancels.
|
||||
#[cfg(any(unix, windows))]
|
||||
use codewhale_runtime::runtime_control::owner_work;
|
||||
pub async fn owner_work<T, F>(work: F) -> anyhow::Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> anyhow::Result<T> + Send + 'static,
|
||||
{
|
||||
static SLOTS: std::sync::OnceLock<std::sync::Arc<tokio::sync::Semaphore>> =
|
||||
std::sync::OnceLock::new();
|
||||
let permit = SLOTS
|
||||
.get_or_init(|| std::sync::Arc::new(tokio::sync::Semaphore::new(4)))
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
work()
|
||||
})
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?
|
||||
}
|
||||
|
||||
// Blocking endpoint mutations retain the actual captured manager through
|
||||
// its existing frontend, even after cancellation of the async caller.
|
||||
@@ -140,20 +374,17 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
use crate::stdio::{
|
||||
ParsedStdioLine, dispatch_stdio_request_with_writer, parse_stdio_line, run_stdio_loop,
|
||||
write_stdio_line,
|
||||
};
|
||||
use crate::{
|
||||
AppState, AppTransport, DispatchError, ShutdownAuthority, StdioLoopExit, StdioLoopPolicy,
|
||||
jsonrpc_error, jsonrpc_result, params_or_object, parse_params,
|
||||
AppState, AppTransport, JsonRpcError, ParsedStdioLine, ShutdownAuthority, StdioLoopExit,
|
||||
StdioLoopPolicy, dispatch_stdio_request_with_writer, jsonrpc_error, jsonrpc_result,
|
||||
params_or_object, parse_params, parse_stdio_line, run_stdio_loop, write_stdio_line,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufRead, AsyncWrite, AsyncWriteExt as _, BufReader};
|
||||
use tokio::io::{AsyncBufRead, AsyncWrite, BufReader};
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
@@ -284,7 +515,7 @@ impl ConnectionTasks {
|
||||
/// refused with `attach_required` until the client attaches.
|
||||
async fn handshake<R, W>(
|
||||
context: &ConnectionContext,
|
||||
lines: &mut crate::stdio::BoundedLines<R>,
|
||||
lines: &mut crate::BoundedLines<R>,
|
||||
writer: &mut W,
|
||||
) -> Result<Option<ConnectionGuard>>
|
||||
where
|
||||
@@ -297,12 +528,13 @@ where
|
||||
};
|
||||
let request = match parse_stdio_line(&line) {
|
||||
ParsedStdioLine::Blank => continue,
|
||||
ParsedStdioLine::Ignored | ParsedStdioLine::Response(_) => continue,
|
||||
ParsedStdioLine::Rejected(response) => {
|
||||
write_stdio_line(writer, &response).await?;
|
||||
continue;
|
||||
}
|
||||
ParsedStdioLine::Request(request) => request,
|
||||
};
|
||||
let Some(id) = request.id.clone() else {
|
||||
continue;
|
||||
};
|
||||
let id = request.id.clone();
|
||||
match request.method.as_str() {
|
||||
"healthz" | "app/healthz" => {
|
||||
let response = match dispatch_stdio_request_with_writer(
|
||||
@@ -311,8 +543,6 @@ where
|
||||
&request.method,
|
||||
request.params,
|
||||
AppTransport::Socket,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -331,7 +561,7 @@ where
|
||||
other => {
|
||||
write_stdio_line(
|
||||
writer,
|
||||
&jsonrpc_error(id, DispatchError::attach_required(other)),
|
||||
&jsonrpc_error(id, JsonRpcError::attach_required(other)),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -342,7 +572,7 @@ where
|
||||
async fn attach(
|
||||
context: &ConnectionContext,
|
||||
params: Value,
|
||||
) -> Result<(Value, ConnectionGuard), DispatchError> {
|
||||
) -> Result<(Value, ConnectionGuard), JsonRpcError> {
|
||||
let params: AttachParams = parse_params(params_or_object(params))?;
|
||||
if params.frontend == AttachFrontend::Acp
|
||||
&& (context.state.owner_frontend.is_none()
|
||||
@@ -352,21 +582,21 @@ async fn attach(
|
||||
.as_ref()
|
||||
.is_some_and(|routing| routing.acp))
|
||||
{
|
||||
return Err(DispatchError::invalid_params(
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"held owner has no narrowed ACP projection; cross-profile attachment refused",
|
||||
));
|
||||
}
|
||||
if (params.frontend == AttachFrontend::Listener) != params.listener.is_some() {
|
||||
return Err(DispatchError::invalid_params(
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"listener selection does not match frontend",
|
||||
));
|
||||
}
|
||||
if let Some(listener) = params.listener.as_ref() {
|
||||
listener.validate_bounds().map_err(|_| {
|
||||
DispatchError::invalid_params("selected frontend input exceeds its bounds")
|
||||
JsonRpcError::invalid_params("selected frontend input exceeds its bounds")
|
||||
})?;
|
||||
if context.state.owner_frontend.is_none() {
|
||||
return Err(DispatchError::invalid_params(
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"held owner has no selected listener projection",
|
||||
));
|
||||
}
|
||||
@@ -375,13 +605,13 @@ async fn attach(
|
||||
&& (params.frontend == AttachFrontend::Listener || context.state.owner_frontend.is_none())
|
||||
|| params.acp_model.is_some() && params.frontend != AttachFrontend::Acp
|
||||
{
|
||||
return Err(DispatchError::invalid_params(
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"selected scope does not match captured frontend",
|
||||
));
|
||||
}
|
||||
if let Some(scope) = params.scope.as_ref() {
|
||||
scope.validate_bounds().map_err(|_| {
|
||||
DispatchError::invalid_params("selected frontend scope exceeds its bounds")
|
||||
JsonRpcError::invalid_params("selected frontend scope exceeds its bounds")
|
||||
})?;
|
||||
}
|
||||
if params
|
||||
@@ -389,27 +619,27 @@ async fn attach(
|
||||
.as_ref()
|
||||
.is_some_and(|model| model.trim().is_empty() || model.len() > 1024)
|
||||
{
|
||||
return Err(DispatchError::invalid_params("invalid selected ACP model"));
|
||||
return Err(JsonRpcError::invalid_params("invalid selected ACP model"));
|
||||
}
|
||||
if let Some(owner) = context.owner.as_ref() {
|
||||
if params.expect_owner.as_ref() != Some(owner) {
|
||||
return Err(DispatchError::invalid_params(
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"captured owner generation or store does not match",
|
||||
));
|
||||
}
|
||||
if params.mode == AttachMode::Claim {
|
||||
return Err(DispatchError::not_daemon_owner());
|
||||
return Err(JsonRpcError::not_daemon_owner());
|
||||
}
|
||||
}
|
||||
if params.client.name.trim().is_empty() {
|
||||
return Err(DispatchError::invalid_params(
|
||||
return Err(JsonRpcError::invalid_params(
|
||||
"client.name must not be empty",
|
||||
));
|
||||
}
|
||||
if let Some(expected) = params.expect_daemon_version.as_deref()
|
||||
&& expected != context.info.version
|
||||
{
|
||||
return Err(DispatchError::daemon_version_skew(
|
||||
return Err(JsonRpcError::daemon_version_skew(
|
||||
expected,
|
||||
context.info.version,
|
||||
));
|
||||
@@ -433,13 +663,13 @@ async fn attach(
|
||||
frontend
|
||||
.validate_selection(&selection)
|
||||
.await
|
||||
.map_err(|error| DispatchError::invalid_params(error.to_string()))?;
|
||||
.map_err(|error| JsonRpcError::invalid_params(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let mut registry = context
|
||||
.registry
|
||||
.lock()
|
||||
.map_err(|_| DispatchError::internal("daemon connection registry poisoned"))?;
|
||||
.map_err(|_| JsonRpcError::internal("daemon connection registry poisoned"))?;
|
||||
let id = registry.register(params.client.clone());
|
||||
let role = match params.mode {
|
||||
AttachMode::Attach => AttachRole::Attached,
|
||||
@@ -448,8 +678,8 @@ async fn attach(
|
||||
Err(owner) => {
|
||||
registry.remove(id);
|
||||
let owner = serde_json::to_value(owner)
|
||||
.map_err(|err| DispatchError::internal(err.to_string()))?;
|
||||
return Err(DispatchError::daemon_already_claimed(&owner));
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?;
|
||||
return Err(JsonRpcError::daemon_already_claimed(&owner));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -544,7 +774,7 @@ pub(crate) async fn connection_context(
|
||||
pub(crate) async fn run_authorized_connection<R, W>(
|
||||
context: ConnectionContext,
|
||||
read: R,
|
||||
writer: W,
|
||||
mut writer: W,
|
||||
peer: AuthorizedPeer,
|
||||
) where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
@@ -555,15 +785,7 @@ pub(crate) async fn run_authorized_connection<R, W>(
|
||||
if owner_work(move || check.check()).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let transport = match crate::websocket::accept(read, writer).await {
|
||||
Ok(transport) => transport,
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "private WebSocket upgrade refused");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (read, mut writer) = tokio::io::split(transport);
|
||||
let mut lines = crate::stdio::BoundedLines::new(BufReader::new(read));
|
||||
let mut lines = crate::BoundedLines::new(BufReader::new(read));
|
||||
let guard = match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
handshake(&context, &mut lines, &mut writer),
|
||||
@@ -606,13 +828,12 @@ pub(crate) async fn run_authorized_connection<R, W>(
|
||||
selection,
|
||||
context.state.clone(),
|
||||
Box::new(lines.into_inner()),
|
||||
Box::new(&mut writer),
|
||||
Box::new(writer),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(%error,"captured frontend connection ended");
|
||||
}
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), writer.shutdown()).await;
|
||||
return;
|
||||
}
|
||||
let policy = StdioLoopPolicy {
|
||||
@@ -622,16 +843,7 @@ pub(crate) async fn run_authorized_connection<R, W>(
|
||||
AttachRole::Attached => ShutdownAuthority::Denied,
|
||||
},
|
||||
};
|
||||
let result = run_stdio_loop(
|
||||
&context.state,
|
||||
lines,
|
||||
&mut writer,
|
||||
policy,
|
||||
Some((guard, peer)),
|
||||
)
|
||||
.await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), writer.shutdown()).await;
|
||||
match result {
|
||||
match run_stdio_loop(&context.state, lines, writer, policy, Some((guard, peer))).await {
|
||||
Ok(StdioLoopExit::Shutdown) => context.shutdown.trigger(),
|
||||
Ok(StdioLoopExit::InputClosed) => {}
|
||||
Err(error) => tracing::debug!(%error,"local control connection ended"),
|
||||
@@ -647,7 +859,6 @@ mod platform {
|
||||
|
||||
use anyhow::Result;
|
||||
use codewhale_config::private_directory::{PrivateDirectory, PrivateSocketIdentity};
|
||||
use codewhale_paths::daemon_socket::{SocketPathInputs, resolve_socket_path};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::watch;
|
||||
|
||||
@@ -655,7 +866,7 @@ mod platform {
|
||||
use super::{ClientIdentity, ConnectionRegistry};
|
||||
use super::{
|
||||
ConnectionContext, ConnectionTasks, DaemonShutdownHandle, DaemonSocketError,
|
||||
DaemonSocketOptions, socket_owner_work,
|
||||
DaemonSocketOptions, SocketPathInputs, resolve_socket_path, socket_owner_work,
|
||||
};
|
||||
use crate::{AppState, AppTransport, build_state_off_runtime};
|
||||
|
||||
@@ -814,44 +1025,26 @@ mod platform {
|
||||
pub async fn bind_daemon_socket(
|
||||
options: DaemonSocketOptions,
|
||||
) -> Result<DaemonSocket, DaemonSocketError> {
|
||||
// A daemon-only run has nothing else to serve with, so a mount that
|
||||
// cannot carry the endpoint is a failure here rather than a degraded
|
||||
// start. `bind_with_owner` reports it as such when `degrade` is false.
|
||||
match bind_with_owner(options, None, None, false).await? {
|
||||
Some(socket) => Ok(socket),
|
||||
// A non-degrading bind reports a mount it cannot use as an error,
|
||||
// so this is an internal invariant rather than a runtime state.
|
||||
None => Err(DaemonSocketError::State(anyhow::anyhow!(
|
||||
"a bind that cannot degrade returned no endpoint"
|
||||
))),
|
||||
}
|
||||
bind_with_owner(options, None, None).await
|
||||
}
|
||||
|
||||
/// Bind the control endpoint for an owner that also has an HTTP frontend.
|
||||
///
|
||||
/// `None` means this filesystem cannot hold a private endpoint directory.
|
||||
/// The owner still serves HTTP, which is what its clients connect to; what
|
||||
/// it loses is the socket a second client would use to discover it. The
|
||||
/// store owner lock still keeps a second engine out of the same store, so
|
||||
/// this degrades discovery, not mutual exclusion.
|
||||
pub(crate) async fn bind_captured_owner(
|
||||
state: AppState,
|
||||
owner: super::RuntimeOwnerReceipt,
|
||||
recovery: Option<Arc<crate::daemon_client::UnavailablePublication>>,
|
||||
) -> Result<Option<DaemonSocket>, DaemonSocketError> {
|
||||
) -> Result<DaemonSocket, DaemonSocketError> {
|
||||
let options = DaemonSocketOptions {
|
||||
socket_path: Some(owner.socket_path.clone()),
|
||||
config_path: None,
|
||||
};
|
||||
bind_with_owner(options, Some((state, owner)), recovery, true).await
|
||||
bind_with_owner(options, Some((state, owner)), recovery).await
|
||||
}
|
||||
|
||||
async fn bind_with_owner(
|
||||
options: DaemonSocketOptions,
|
||||
captured: Option<(AppState, super::RuntimeOwnerReceipt)>,
|
||||
recovery: Option<Arc<crate::daemon_client::UnavailablePublication>>,
|
||||
degrade: bool,
|
||||
) -> Result<Option<DaemonSocket>, DaemonSocketError> {
|
||||
) -> Result<DaemonSocket, DaemonSocketError> {
|
||||
let owner_state = captured.as_ref().map(|(state, _)| state.clone());
|
||||
let path = resolve_socket_path(&SocketPathInputs::from_environment(options.socket_path)?)?;
|
||||
let parent_path = path
|
||||
@@ -882,57 +1075,13 @@ mod platform {
|
||||
}
|
||||
previous.publication.parent.clone()
|
||||
} else {
|
||||
// The strict admission first: on a filesystem that stores owner and
|
||||
// mode it succeeds and nothing here costs anything extra. Only a
|
||||
// failure asks the mount what it can express, which is what turns
|
||||
// the opaque "failed to build daemon state" into either a reason to
|
||||
// continue without the endpoint or a named refusal.
|
||||
let admitted = socket_owner_work(owner_state.clone(), {
|
||||
let parent_path = parent_path.clone();
|
||||
move || PrivateDirectory::admit(&parent_path)
|
||||
})
|
||||
.await;
|
||||
match admitted {
|
||||
Ok(directory) => Arc::new(directory),
|
||||
Err(error) => {
|
||||
if let codewhale_config::private_directory::PrivateFooting::Synthesised {
|
||||
observed_uid,
|
||||
observed_mode,
|
||||
} = socket_owner_work(owner_state.clone(), {
|
||||
let parent_path = parent_path.clone();
|
||||
move || {
|
||||
Ok(codewhale_config::private_directory::mount_footing(
|
||||
&parent_path,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(DaemonSocketError::State)?
|
||||
{
|
||||
if degrade {
|
||||
tracing::warn!(
|
||||
endpoint = %path.display(),
|
||||
"{} cannot provide workspace control-socket discovery: the \
|
||||
filesystem reports uid {observed_uid} and mode \
|
||||
{observed_mode:o} for objects created on it, so a private \
|
||||
endpoint directory cannot be verified here; serving the control \
|
||||
API over HTTP only",
|
||||
parent_path.display()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(DaemonSocketError::EndpointDirectoryUnsupported {
|
||||
path: parent_path.clone(),
|
||||
reason: format!(
|
||||
"the filesystem reports uid {observed_uid} and mode \
|
||||
{observed_mode:o} for objects created on it, so a private \
|
||||
endpoint directory cannot be verified there"
|
||||
),
|
||||
});
|
||||
}
|
||||
return Err(DaemonSocketError::State(error));
|
||||
}
|
||||
}
|
||||
Arc::new(
|
||||
socket_owner_work(owner_state.clone(), move || {
|
||||
PrivateDirectory::admit(&parent_path)
|
||||
})
|
||||
.await
|
||||
.map_err(DaemonSocketError::State)?,
|
||||
)
|
||||
};
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -1067,14 +1216,14 @@ mod platform {
|
||||
.map_err(DaemonSocketError::State)?;
|
||||
}
|
||||
let (shutdown, _) = watch::channel(false);
|
||||
Ok(Some(DaemonSocket {
|
||||
Ok(DaemonSocket {
|
||||
listener,
|
||||
path,
|
||||
state,
|
||||
shutdown: Arc::new(shutdown),
|
||||
socket_file,
|
||||
owner,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Only a definite refusal permits exact captured endpoint retirement.
|
||||
@@ -1195,33 +1344,6 @@ mod platform {
|
||||
_held: Arc<()>,
|
||||
}
|
||||
impl crate::RuntimeOwnerFrontend for FrontendLifetime {
|
||||
fn execute(
|
||||
&self,
|
||||
_request: crate::RuntimeOwnerRequest,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<Output = anyhow::Result<serde_json::Value>> + Send + '_,
|
||||
>,
|
||||
> {
|
||||
Box::pin(async {
|
||||
anyhow::bail!("this captured frontend admits no ordinary native service")
|
||||
})
|
||||
}
|
||||
fn events(
|
||||
&self,
|
||||
_thread_id: String,
|
||||
_since_seq: u64,
|
||||
) -> std::pin::Pin<
|
||||
Box<
|
||||
dyn std::future::Future<Output = anyhow::Result<crate::RuntimeOwnerEventStream>>
|
||||
+ Send
|
||||
+ '_,
|
||||
>,
|
||||
> {
|
||||
Box::pin(async {
|
||||
anyhow::bail!("this captured frontend admits no ordinary native observation")
|
||||
})
|
||||
}
|
||||
fn validate_selection<'a>(
|
||||
&'a self,
|
||||
_selection: &'a crate::RuntimeOwnerFrontendSelection,
|
||||
@@ -1229,13 +1351,13 @@ mod platform {
|
||||
{
|
||||
Box::pin(async { anyhow::bail!("lifetime-only frontend cannot admit a command") })
|
||||
}
|
||||
fn serve<'a>(
|
||||
&'a self,
|
||||
fn serve(
|
||||
&self,
|
||||
_selection: crate::RuntimeOwnerFrontendSelection,
|
||||
_compatibility: AppState,
|
||||
_input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
|
||||
_output: Box<dyn tokio::io::AsyncWrite + Send + Unpin + 'a>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>
|
||||
_output: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
|
||||
{
|
||||
Box::pin(async { anyhow::bail!("lifetime-only frontend cannot serve a command") })
|
||||
}
|
||||
@@ -1612,6 +1734,23 @@ mod platform {
|
||||
use crate::daemon_windows as platform;
|
||||
pub use platform::{DaemonSocket, bind_daemon_socket};
|
||||
|
||||
/// `codewhale app-server --socket`: bind, announce, serve until the owner's
|
||||
/// `shutdown` or a termination signal.
|
||||
pub async fn run_daemon_socket(options: DaemonSocketOptions) -> anyhow::Result<()> {
|
||||
let daemon = bind_daemon_socket(options).await?;
|
||||
let path: &Path = daemon.local_path();
|
||||
tracing::info!(path = %path.display(), "codewhale daemon listening on unix socket");
|
||||
eprintln!("codewhale daemon: listening on {}", path.display());
|
||||
|
||||
let handle = daemon.shutdown_handle();
|
||||
tokio::spawn(async move {
|
||||
crate::shutdown_signal().await;
|
||||
handle.trigger();
|
||||
});
|
||||
daemon.serve().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) use platform::bind_captured_owner;
|
||||
|
||||
@@ -1640,15 +1779,13 @@ pub async fn capture_process_start(pid: u32) -> anyhow::Result<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
|
||||
fn inputs() -> SocketPathInputs {
|
||||
SocketPathInputs {
|
||||
explicit: None,
|
||||
codewhale_home_override: None,
|
||||
xdg_runtime_dir: None,
|
||||
user_home: Some(PathBuf::from("/cw-test-home/whale")),
|
||||
user_home: Some(PathBuf::from("/home/whale")),
|
||||
macos: false,
|
||||
}
|
||||
}
|
||||
@@ -1709,7 +1846,7 @@ mod tests {
|
||||
let resolved = resolve_socket_path(&inputs()).expect("resolve");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("/cw-test-home/whale/.codewhale/run/daemon.sock")
|
||||
PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1719,7 +1856,7 @@ mod tests {
|
||||
let resolved = store_selected_socket_path(None, &inputs()).expect("resolve");
|
||||
assert_eq!(
|
||||
resolved,
|
||||
PathBuf::from("/cw-test-home/whale/.codewhale/run/daemon.sock")
|
||||
PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1727,18 +1864,18 @@ mod tests {
|
||||
#[test]
|
||||
fn distinct_stores_derive_distinct_sibling_sockets() {
|
||||
let one = store_selected_socket_path(
|
||||
Some(Path::new("/cw-test-home/whale/.local/state/one/runtime")),
|
||||
Some(Path::new("/home/whale/.local/state/one/runtime")),
|
||||
&inputs(),
|
||||
)
|
||||
.expect("resolve");
|
||||
let two = store_selected_socket_path(
|
||||
Some(Path::new("/cw-test-home/whale/.local/state/two/runtime")),
|
||||
Some(Path::new("/home/whale/.local/state/two/runtime")),
|
||||
&inputs(),
|
||||
)
|
||||
.expect("resolve");
|
||||
assert_eq!(
|
||||
one.parent().unwrap(),
|
||||
Path::new("/cw-test-home/whale/.codewhale/run")
|
||||
Path::new("/home/whale/.codewhale/run")
|
||||
);
|
||||
assert_eq!(one.parent().unwrap(), two.parent().unwrap());
|
||||
assert_ne!(one, two);
|
||||
@@ -1751,7 +1888,7 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_derived_socket_is_a_pure_function_of_the_store_root() {
|
||||
let store = Path::new("/cw-test-home/whale/.local/state/one/runtime");
|
||||
let store = Path::new("/home/whale/.local/state/one/runtime");
|
||||
let first = store_selected_socket_path(Some(store), &inputs()).expect("resolve");
|
||||
let second = store_selected_socket_path(Some(store), &inputs()).expect("resolve");
|
||||
assert_eq!(first, second);
|
||||
|
||||
Loaded 100 of 2711 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user