mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 10:01:30 -10:00
Merge remote-tracking branch 'origin/main' into docs/codewhale-agent-identity-20260828
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
commit
b37699142c
31 files changed
+1732
-4
No files matched your search
@@ -61,6 +61,16 @@ jobs:
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install native build deps
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
run: |
|
||||
for i in 1 2 3; do
|
||||
sudo apt-get update && break
|
||||
echo "apt-get update failed (attempt $i); retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
sudo apt-get install -y libdbus-1-dev pkg-config
|
||||
|
||||
- name: Cache cargo build
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -81,4 +91,19 @@ jobs:
|
||||
if [ -n "${CODEWHALE_REVIEW_MODEL:-}" ]; then
|
||||
MODEL_ARGS=(--model "$CODEWHALE_REVIEW_MODEL")
|
||||
fi
|
||||
./target/release/codewhale review --pr "$PR_NUMBER" --post "${MODEL_ARGS[@]}"
|
||||
set +e
|
||||
OUTPUT=$(./target/release/codewhale review --pr "$PR_NUMBER" --post "${MODEL_ARGS[@]}" 2>&1)
|
||||
STATUS=$?
|
||||
set -e
|
||||
echo "$OUTPUT"
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
# The review is advisory: a provider-side outage (balance, auth,
|
||||
# rate limit, upstream 5xx) must not block the PR. Real review
|
||||
# failures still fail the job with the original exit status.
|
||||
if echo "$OUTPUT" | grep -qE 'LLM error: HTTP (401|402|403|408|429|5[0-9][0-9])'; then
|
||||
echo "::warning::Codewhale review could not run (provider error; see log above). The PR is not blocked — provider funding/config is founder-gated."
|
||||
exit 0
|
||||
fi
|
||||
exit "$STATUS"
|
||||
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Add `codewhale dispatch` / `/dispatch` so a local session can propose a
|
||||
Codewhale cloud agent against an explicit `github`, `cnb`, or `gitee` remote.
|
||||
Confirmation is required; missing credentials fail closed; cloud jobs share
|
||||
the existing `/jobs` surface as `kind=cloud`. See
|
||||
[DAYTONA_CLOUD_DISPATCH.md](docs/DAYTONA_CLOUD_DISPATCH.md).
|
||||
- `/login` reports the Codewhale account session and provider-key next
|
||||
steps. The internal cloud-agent credential is not user surface: there is
|
||||
no `auth set-slot`/`auth clear-slot` command, no hint, and no completion
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! First-class Daytona cloud-agent offload: `codewhale dispatch`.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Args, ValueEnum};
|
||||
use codewhale_tui::cloud_dispatch::{
|
||||
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
|
||||
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
|
||||
format_status, plan_dispatch,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
enum ForgeArg {
|
||||
Github,
|
||||
Cnb,
|
||||
Gitee,
|
||||
}
|
||||
|
||||
impl From<ForgeArg> for Forge {
|
||||
fn from(value: ForgeArg) -> Self {
|
||||
match value {
|
||||
ForgeArg::Github => Forge::Github,
|
||||
ForgeArg::Cnb => Forge::Cnb,
|
||||
ForgeArg::Gitee => Forge::Gitee,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct DispatchArgs {
|
||||
/// Task for the remote agent. Required unless listing or inspecting a job.
|
||||
#[arg(value_name = "PROMPT")]
|
||||
prompt: Vec<String>,
|
||||
/// Forge that should receive the branch and PR: github, cnb, or gitee.
|
||||
#[arg(long, value_enum)]
|
||||
remote: Option<ForgeArg>,
|
||||
/// Branch the remote agent will raise (default: codewhale/cloud-<unix>).
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
/// Required to create Daytona spend or push. Without this, only a proposal is written.
|
||||
#[arg(long)]
|
||||
confirm: bool,
|
||||
/// Show remotes and whether Daytona credentials are present (never prints secrets).
|
||||
#[arg(long)]
|
||||
status: bool,
|
||||
/// List first-class cloud jobs (same kind shown by `/jobs`).
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
/// Inspect one cloud job.
|
||||
#[arg(long, value_name = "ID")]
|
||||
show: Option<String>,
|
||||
/// Cancel one cloud job.
|
||||
#[arg(long, value_name = "ID")]
|
||||
cancel: Option<String>,
|
||||
/// Workspace whose git remotes are classified (default: current directory).
|
||||
#[arg(long)]
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) fn run(args: DispatchArgs) -> Result<()> {
|
||||
let mut out = io::stdout().lock();
|
||||
run_with(args, &mut out)
|
||||
}
|
||||
|
||||
fn run_with<W: Write>(args: DispatchArgs, out: &mut W) -> Result<()> {
|
||||
if [
|
||||
args.status,
|
||||
args.list,
|
||||
args.show.is_some(),
|
||||
args.cancel.is_some(),
|
||||
!args.prompt.is_empty(),
|
||||
]
|
||||
.iter()
|
||||
.filter(|flag| **flag)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
bail!("Use one of: a prompt, --status, --list, --show <id>, or --cancel <id>.");
|
||||
}
|
||||
|
||||
let workspace = args
|
||||
.cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
let remotes = discover_remotes(&workspace);
|
||||
let credentials = discover_credentials();
|
||||
let store = CloudJobStore::from_env()?;
|
||||
|
||||
if args.status
|
||||
|| (args.prompt.is_empty() && args.show.is_none() && args.cancel.is_none() && !args.list)
|
||||
{
|
||||
writeln!(out, "{}", format_status(&remotes, &credentials))?;
|
||||
return Ok(());
|
||||
}
|
||||
if args.list {
|
||||
writeln!(out, "{}", format_job_list(&store.list()?))?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(id) = args.show.as_deref() {
|
||||
writeln!(out, "{}", format_job(&store.load(id)?))?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(id) = args.cancel.as_deref() {
|
||||
writeln!(out, "{}", format_job(&cancel_job(&store, id)?))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prompt = args.prompt.join(" ");
|
||||
if prompt.starts_with("cloud_") && args.confirm && prompt.split_whitespace().count() == 1 {
|
||||
return write_outcome(
|
||||
out,
|
||||
confirm_job(&store, prompt.trim(), &credentials, &LiveDaytonaLauncher)?,
|
||||
);
|
||||
}
|
||||
|
||||
let plan = plan_dispatch(
|
||||
&remotes,
|
||||
&prompt,
|
||||
args.remote.map(Forge::from),
|
||||
args.branch.as_deref(),
|
||||
)?;
|
||||
let outcome = execute_dispatch(
|
||||
&store,
|
||||
plan,
|
||||
args.confirm,
|
||||
&credentials,
|
||||
&LiveDaytonaLauncher,
|
||||
)?;
|
||||
write_outcome(out, outcome)
|
||||
}
|
||||
|
||||
fn write_outcome<W: Write>(out: &mut W, outcome: DispatchOutcome) -> Result<()> {
|
||||
match outcome {
|
||||
DispatchOutcome::Proposal(job) | DispatchOutcome::Accepted(job) => {
|
||||
writeln!(out, "{}", format_job(&job))?;
|
||||
Ok(())
|
||||
}
|
||||
DispatchOutcome::Refused(job) => {
|
||||
writeln!(out, "{}", format_job(&job))?;
|
||||
bail!("{}", job.note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Cli, Commands};
|
||||
use clap::Parser;
|
||||
|
||||
fn args(argv: &[&str]) -> DispatchArgs {
|
||||
let cli = Cli::try_parse_from(argv).unwrap();
|
||||
let Some(Commands::Dispatch(args)) = cli.command else {
|
||||
panic!("expected dispatch command");
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_obvious_dispatch_command() {
|
||||
let parsed = args(&[
|
||||
"codewhale",
|
||||
"dispatch",
|
||||
"fix",
|
||||
"the",
|
||||
"flake",
|
||||
"--remote",
|
||||
"github",
|
||||
]);
|
||||
assert_eq!(parsed.prompt, ["fix", "the", "flake"]);
|
||||
assert_eq!(parsed.remote, Some(ForgeArg::Github));
|
||||
assert!(!parsed.confirm);
|
||||
assert!(args(&["codewhale", "cloud-agent", "--status"]).status);
|
||||
assert!(Cli::try_parse_from(["codewhale", "dispatch", "--remote", "gitlab"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_is_fail_closed_and_never_prints_secrets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut output = Vec::new();
|
||||
run_with(
|
||||
DispatchArgs {
|
||||
prompt: Vec::new(),
|
||||
remote: None,
|
||||
branch: None,
|
||||
confirm: false,
|
||||
status: true,
|
||||
list: false,
|
||||
show: None,
|
||||
cancel: None,
|
||||
cwd: Some(temp.path().to_path_buf()),
|
||||
},
|
||||
&mut output,
|
||||
)
|
||||
.unwrap();
|
||||
let text = String::from_utf8(output).unwrap();
|
||||
assert!(text.contains("Codewhale cloud dispatch"));
|
||||
assert!(!text.contains("Daytona"));
|
||||
assert!(!text.contains("sk-"));
|
||||
assert!(!text.contains("Bearer"));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod cloud;
|
||||
mod config_bundles;
|
||||
mod credential_handoff;
|
||||
mod dispatch;
|
||||
mod metrics;
|
||||
#[cfg(not(target_env = "ohos"))]
|
||||
mod update;
|
||||
@@ -395,6 +396,9 @@ New integrations should prefer `codewhale app-server`.")]
|
||||
/// Sign in to your Codewhale account and manage account-scoped provider keys.
|
||||
#[command(visible_alias = "cloud")]
|
||||
Account(cloud::CloudArgs),
|
||||
/// Offload a coding agent to Daytona. Never spends or pushes without --confirm.
|
||||
#[command(visible_alias = "cloud-agent")]
|
||||
Dispatch(dispatch::DispatchArgs),
|
||||
/// Run MCP server mode over stdio.
|
||||
McpServer,
|
||||
/// Read/write/list config values.
|
||||
@@ -2081,6 +2085,7 @@ fn run() -> Result<()> {
|
||||
cloud::reject_inline_api_key(cli.api_key.as_deref())?;
|
||||
cloud::run(args, cli.profile.as_deref(), &store)
|
||||
}
|
||||
Some(Commands::Dispatch(args)) => dispatch::run(args),
|
||||
Some(Commands::McpServer) => {
|
||||
// `codewhale serve --mcp` delegates to the TUI and arms there, so
|
||||
// without this the same user action reported differently depending
|
||||
|
||||
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Add `codewhale dispatch` / `/dispatch` so a local session can propose a
|
||||
Codewhale cloud agent against an explicit `github`, `cnb`, or `gitee` remote.
|
||||
Confirmation is required; missing credentials fail closed; cloud jobs share
|
||||
the existing `/jobs` surface as `kind=cloud`. See
|
||||
[DAYTONA_CLOUD_DISPATCH.md](docs/DAYTONA_CLOUD_DISPATCH.md).
|
||||
- `/login` reports the Codewhale account session and provider-key next
|
||||
steps. The internal cloud-agent credential is not user surface: there is
|
||||
no `auth set-slot`/`auth clear-slot` command, no hint, and no completion
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Activa o desactiva els diagnòstics LSP",
|
||||
"CmdShareDescription": "Exporta la sessió actual com a URL web compartible",
|
||||
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
|
||||
"CmdDispatchDescription": "Envia un agent cloud de Codewhale per crear una branca i obrir un PR",
|
||||
"CmdLinksDescription": "Mostra enllaços de Codewhale, la comunitat i els proveïdors",
|
||||
"CmdLoadDescription": "Carrega una sessió des d'un fitxer",
|
||||
"CmdLogoutDescription": "Esborra la clau d'API i torna a la configuració",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "LSP-Diagnosen ein- oder ausschalten",
|
||||
"CmdShareDescription": "Aktuelle Sitzung als teilbare Web-URL exportieren",
|
||||
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
|
||||
"CmdDispatchDescription": "Codewhale-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
|
||||
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
|
||||
"CmdLoadDescription": "Sitzung aus Datei laden",
|
||||
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Toggle LSP diagnostics on or off",
|
||||
"CmdShareDescription": "Export current session as a shareable web URL",
|
||||
"CmdJobsDescription": "Inspect and control background Bash jobs",
|
||||
"CmdDispatchDescription": "Send a Codewhale cloud agent to raise a branch and open a PR",
|
||||
"CmdLinksDescription": "Show Codewhale, community, and provider links",
|
||||
"CmdLoadDescription": "Load session from file",
|
||||
"CmdLogoutDescription": "Clear API key and return to setup",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Alternar diagnóstico LSP encendido o apagado",
|
||||
"CmdShareDescription": "Exportar la sesión actual como una URL web compartible",
|
||||
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
|
||||
"CmdDispatchDescription": "Enviar un agente cloud de Codewhale para crear una rama y abrir un PR",
|
||||
"CmdLinksDescription": "Mostrar enlaces de Codewhale, la comunidad y los proveedores",
|
||||
"CmdLoadDescription": "Cargar la sesión desde un archivo",
|
||||
"CmdLogoutDescription": "Limpiar la clave de API y volver a la configuración",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Activer ou désactiver les diagnostics LSP",
|
||||
"CmdShareDescription": "Exporter la session en cours en URL web partageable",
|
||||
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
|
||||
"CmdDispatchDescription": "Envoyer un agent cloud Codewhale pour créer une branche et ouvrir une PR",
|
||||
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
|
||||
"CmdLoadDescription": "Charger une session depuis un fichier",
|
||||
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "LSP डायग्नोस्टिक्स चालू/बंद करें",
|
||||
"CmdShareDescription": "वर्तमान सत्र को साझा करने योग्य वेब URL के रूप में एक्सपोर्ट करें",
|
||||
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
|
||||
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Codewhale क्लाउड एजेंट भेजें",
|
||||
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
|
||||
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
|
||||
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Aktifkan atau nonaktifkan diagnostik LSP",
|
||||
"CmdShareDescription": "Ekspor sesi saat ini sebagai URL web yang dapat dibagikan",
|
||||
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
|
||||
"CmdDispatchDescription": "Kirim agen cloud Codewhale untuk membuat branch dan membuka PR",
|
||||
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
|
||||
"CmdLoadDescription": "Muat sesi dari file",
|
||||
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "LSP 診断のオン・オフを切り替え",
|
||||
"CmdShareDescription": "現在のセッションを共有可能な Web URL としてエクスポート",
|
||||
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
|
||||
"CmdDispatchDescription": "Codewhale クラウドエージェントを送り、ブランチと PR を作成",
|
||||
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
|
||||
"CmdLoadDescription": "ファイルからセッションを読み込み",
|
||||
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "LSP 진단을 켜거나 끕니다",
|
||||
"CmdShareDescription": "현재 세션을 공유 가능한 웹 URL로 내보냅니다",
|
||||
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
|
||||
"CmdDispatchDescription": "Codewhale 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
|
||||
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
|
||||
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
|
||||
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Alternar diagnóstico LSP ligado ou desligado",
|
||||
"CmdShareDescription": "Exportar a sessão atual como uma URL web compartilhável",
|
||||
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
|
||||
"CmdDispatchDescription": "Enviar um agente cloud da Codewhale para criar um branch e abrir um PR",
|
||||
"CmdLinksDescription": "Exibir links do Codewhale, da comunidade e dos provedores",
|
||||
"CmdLoadDescription": "Carregar a sessão de um arquivo",
|
||||
"CmdLogoutDescription": "Limpar a chave de API e voltar à configuração",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Включить или выключить диагностику LSP",
|
||||
"CmdShareDescription": "Экспортировать текущую сессию как веб-ссылку для общего доступа",
|
||||
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
|
||||
"CmdDispatchDescription": "Отправить облачного агента Codewhale, чтобы создать ветку и открыть PR",
|
||||
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
|
||||
"CmdLoadDescription": "Загрузить сессию из файла",
|
||||
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Увімкнути або вимкнути діагностику LSP",
|
||||
"CmdShareDescription": "Експортувати поточний сеанс як веб-URL для поширення",
|
||||
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
|
||||
"CmdDispatchDescription": "Надіслати хмарного агента Codewhale, щоб створити гілку й відкрити PR",
|
||||
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
|
||||
"CmdLoadDescription": "Завантажити сеанс з файлу",
|
||||
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Bật hoặc tắt tính năng chẩn đoán LSP",
|
||||
"CmdShareDescription": "Xuất phiên hiện tại thành một liên kết web có thể chia sẻ",
|
||||
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
|
||||
"CmdDispatchDescription": "Gửi tác nhân đám mây Codewhale để tạo nhánh và mở PR",
|
||||
"CmdLinksDescription": "Hiển thị liên kết Codewhale, cộng đồng và nhà cung cấp",
|
||||
"CmdLoadDescription": "Tải phiên làm việc từ tệp",
|
||||
"CmdLogoutDescription": "Xóa khóa API và quay lại bước thiết lập",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "切换 LSP 诊断的开启或关闭",
|
||||
"CmdShareDescription": "将当前会话导出为可共享的 Web URL",
|
||||
"CmdJobsDescription": "查看并管理后台 shell 作业",
|
||||
"CmdDispatchDescription": "派遣 Codewhale 云端代理以创建分支并打开拉取请求",
|
||||
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
|
||||
"CmdLoadDescription": "从文件加载会话",
|
||||
"CmdLogoutDescription": "清除 API 密钥并返回设置",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"CmdHotbarDescription": "開啟 Hotbar 設定",
|
||||
"CmdInitDescription": "為項目生成 AGENTS.md",
|
||||
"CmdJobsDescription": "檢視並管理後台 shell 作業",
|
||||
"CmdDispatchDescription": "派遣 Codewhale 雲端代理以建立分支並開啟拉取請求",
|
||||
"CmdLaneDescription": "檢視與控制持久化的 Lane(執行中的工作流程)",
|
||||
"CmdLinksDescription": "顯示 Codewhale、社群與提供商鏈接",
|
||||
"CmdLoadDescription": "從檔案載入工作階段",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -181,6 +181,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
|
||||
"cmd_hotbar_description" => MessageId::CmdHotbarDescription,
|
||||
"cmd_init_description" => MessageId::CmdInitDescription,
|
||||
"cmd_jobs_description" => MessageId::CmdJobsDescription,
|
||||
"cmd_dispatch_description" => MessageId::CmdDispatchDescription,
|
||||
"cmd_lane_description" => MessageId::CmdLaneDescription,
|
||||
"cmd_links_description" => MessageId::CmdLinksDescription,
|
||||
"cmd_load_description" => MessageId::CmdLoadDescription,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! `/dispatch` — first-class Codewhale cloud-agent offload.
|
||||
|
||||
use codewhale_command_contract::facets::CommandWorkspaceContext;
|
||||
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
|
||||
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
|
||||
|
||||
use crate::cloud_dispatch::{
|
||||
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
|
||||
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
|
||||
format_status, plan_dispatch,
|
||||
};
|
||||
use crate::commands::CommandResult;
|
||||
|
||||
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
|
||||
name: "dispatch",
|
||||
aliases: &["cloud-agent", "cloud-dispatch"],
|
||||
usage: "/dispatch [list|show <id>|confirm <id>|cancel <id>|<prompt> [--remote github|cnb|gitee]]",
|
||||
description_key: "cmd_dispatch_description",
|
||||
};
|
||||
|
||||
pub(in crate::commands) struct DispatchCmd;
|
||||
|
||||
impl RegisterCommand<CommandResult> for DispatchCmd {
|
||||
fn info() -> &'static CommandInfo {
|
||||
&COMMAND_INFO
|
||||
}
|
||||
|
||||
fn handler() -> CommandHandler<CommandResult> {
|
||||
CommandHandler::Contextual(dispatch_contextual)
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
|
||||
let mut parts = contexts.into_parts();
|
||||
let workspace = parts.workspace.as_deref_mut().expect("workspace facet");
|
||||
dispatch(workspace, arg)
|
||||
}
|
||||
|
||||
fn dispatch(workspace: &mut dyn CommandWorkspaceContext, args: Option<&str>) -> CommandResult {
|
||||
let raw = args.unwrap_or("").trim();
|
||||
let store = match CloudJobStore::from_env() {
|
||||
Ok(store) => store,
|
||||
Err(error) => return CommandResult::error(error.to_string()),
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return CommandResult::message(format_status(
|
||||
&discover_remotes(&workspace.workspace()),
|
||||
&discover_credentials(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let verb = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
|
||||
match verb.as_str() {
|
||||
"list" => match store.list() {
|
||||
Ok(jobs) => CommandResult::message(format_job_list(&jobs)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
},
|
||||
"show" | "inspect" => {
|
||||
if rest.is_empty() {
|
||||
return CommandResult::error("Usage: /dispatch show <id>");
|
||||
}
|
||||
match store.load(rest) {
|
||||
Ok(job) => CommandResult::message(format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
"confirm" => {
|
||||
if rest.is_empty() {
|
||||
return CommandResult::error("Usage: /dispatch confirm <id>");
|
||||
}
|
||||
match confirm_job(&store, rest, &discover_credentials(), &LiveDaytonaLauncher) {
|
||||
Ok(outcome) => CommandResult::message(outcome_message(&outcome)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
"cancel" | "kill" | "stop" => {
|
||||
if rest.is_empty() {
|
||||
return CommandResult::error("Usage: /dispatch cancel <id>");
|
||||
}
|
||||
match cancel_job(&store, rest) {
|
||||
Ok(job) => CommandResult::message(format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
_ => propose_or_run(workspace, &store, raw, false),
|
||||
}
|
||||
}
|
||||
|
||||
fn propose_or_run(
|
||||
workspace: &mut dyn CommandWorkspaceContext,
|
||||
store: &CloudJobStore,
|
||||
raw: &str,
|
||||
confirm: bool,
|
||||
) -> CommandResult {
|
||||
let (prompt, requested) = match split_prompt_and_remote(raw) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => return CommandResult::error(error),
|
||||
};
|
||||
let remotes = discover_remotes(&workspace.workspace());
|
||||
let plan = match plan_dispatch(&remotes, &prompt, requested, None) {
|
||||
Ok(plan) => plan,
|
||||
Err(error) => return CommandResult::error(error.to_string()),
|
||||
};
|
||||
match execute_dispatch(
|
||||
store,
|
||||
plan,
|
||||
confirm,
|
||||
&discover_credentials(),
|
||||
&LiveDaytonaLauncher,
|
||||
) {
|
||||
Ok(outcome) => CommandResult::message(outcome_message(&outcome)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_prompt_and_remote(raw: &str) -> Result<(String, Option<Forge>), String> {
|
||||
let mut remote = None;
|
||||
let mut prompt_parts = Vec::new();
|
||||
let mut tokens = raw.split_whitespace().peekable();
|
||||
while let Some(token) = tokens.next() {
|
||||
if token == "--remote" {
|
||||
let Some(value) = tokens.next() else {
|
||||
return Err("Usage: /dispatch <prompt> --remote github|cnb|gitee".to_string());
|
||||
};
|
||||
remote = Some(
|
||||
Forge::parse(value)
|
||||
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = token.strip_prefix("--remote=") {
|
||||
remote = Some(
|
||||
Forge::parse(value)
|
||||
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
prompt_parts.push(token);
|
||||
}
|
||||
let prompt = prompt_parts.join(" ");
|
||||
if prompt.is_empty() {
|
||||
return Err("Usage: /dispatch <prompt> [--remote github|cnb|gitee]".to_string());
|
||||
}
|
||||
Ok((prompt, remote))
|
||||
}
|
||||
|
||||
fn outcome_message(outcome: &DispatchOutcome) -> String {
|
||||
match outcome {
|
||||
DispatchOutcome::Proposal(job)
|
||||
| DispatchOutcome::Refused(job)
|
||||
| DispatchOutcome::Accepted(job) => format_job(job),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct FakeWorkspace(PathBuf);
|
||||
impl CommandWorkspaceContext for FakeWorkspace {
|
||||
fn workspace(&self) -> PathBuf {
|
||||
self.0.clone()
|
||||
}
|
||||
fn work_state_snapshot(&self) -> Result<Option<String>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
fn operation_digest(&mut self) -> Result<String, String> {
|
||||
Ok("digest".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_prompt_and_explicit_remote() {
|
||||
let (prompt, remote) = split_prompt_and_remote("fix the flake --remote cnb").unwrap();
|
||||
assert_eq!(prompt, "fix the flake");
|
||||
assert_eq!(remote, Some(Forge::Cnb));
|
||||
assert!(split_prompt_and_remote("--remote gitee").is_err());
|
||||
assert_eq!(
|
||||
split_prompt_and_remote("open pr --remote=github")
|
||||
.unwrap()
|
||||
.1,
|
||||
Some(Forge::Github)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_is_contextual_and_argument_aware() {
|
||||
assert!(matches!(
|
||||
DispatchCmd::handler(),
|
||||
CommandHandler::Contextual(_)
|
||||
));
|
||||
assert_eq!(
|
||||
DispatchCmd::info().description_key,
|
||||
"cmd_dispatch_description"
|
||||
);
|
||||
assert_eq!(
|
||||
DispatchCmd::info().aliases,
|
||||
&["cloud-agent", "cloud-dispatch"]
|
||||
);
|
||||
assert!(DispatchCmd::info().usage.starts_with("/dispatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_dispatch_is_a_status_card_not_a_silent_launch() {
|
||||
let mut workspace = FakeWorkspace(PathBuf::from("."));
|
||||
let result = dispatch(&mut workspace, None);
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
let message = result.message.expect("status message");
|
||||
assert!(message.contains("Cloud agents"));
|
||||
assert!(message.contains("fails closed") || message.contains("ready"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ fn jobs(args: Option<&str>) -> CommandResult {
|
||||
match action.as_str() {
|
||||
"list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
|
||||
"show" | "inspect" => match id {
|
||||
Some(id) if id.starts_with("cloud_") => show_cloud_job(id),
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
|
||||
id: id.to_string(),
|
||||
})),
|
||||
@@ -74,6 +75,7 @@ fn jobs(args: Option<&str>) -> CommandResult {
|
||||
None => CommandResult::error("Usage: /jobs close-stdin <id>"),
|
||||
},
|
||||
"cancel" | "kill" | "stop" => match id {
|
||||
Some(id) if id.starts_with("cloud_") => cancel_cloud_job(id),
|
||||
Some(id) if is_cancel_all_token(id) => {
|
||||
CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll))
|
||||
}
|
||||
@@ -93,6 +95,22 @@ fn jobs(args: Option<&str>) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn show_cloud_job(id: &str) -> CommandResult {
|
||||
match crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.load(id)) {
|
||||
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_cloud_job(id: &str) -> CommandResult {
|
||||
match crate::cloud_dispatch::CloudJobStore::from_env()
|
||||
.and_then(|store| crate::cloud_dispatch::cancel_job(&store, id))
|
||||
{
|
||||
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -118,6 +136,10 @@ mod tests {
|
||||
Some(AppAction::ShellJob(ShellJobAction::CancelAll))
|
||||
));
|
||||
|
||||
let cloud = jobs(Some("show cloud_deadbeef"));
|
||||
assert!(cloud.action.is_none());
|
||||
assert!(cloud.message.is_some() || cloud.is_error);
|
||||
|
||||
let cancel_all_spaced = jobs(Some("cancel all"));
|
||||
assert!(matches!(
|
||||
cancel_all_spaced.action,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
mod attachment;
|
||||
mod automation;
|
||||
mod dispatch;
|
||||
mod jobs;
|
||||
mod mcp;
|
||||
mod network;
|
||||
@@ -30,6 +31,10 @@ impl CommandGroup for UtilityCommands {
|
||||
Box::new(
|
||||
ContextualCommand::from_contract::<jobs::JobsCmd>().expect("jobs registration"),
|
||||
),
|
||||
Box::new(
|
||||
ContextualCommand::from_contract::<dispatch::DispatchCmd>()
|
||||
.expect("dispatch registration"),
|
||||
),
|
||||
Box::new(ContextualCommand::from_contract::<mcp::McpCmd>().expect("mcp registration"),),
|
||||
Box::new(
|
||||
ContextualCommand::from_contract::<network::NetworkCmd>()
|
||||
|
||||
@@ -1112,6 +1112,7 @@ mod tests {
|
||||
fn flagship_orchestration_and_workspace_commands_are_visible_at_the_palette_root() {
|
||||
for name in [
|
||||
"auto",
|
||||
"dispatch",
|
||||
"goal",
|
||||
"hooks",
|
||||
"tokens",
|
||||
@@ -1892,11 +1893,13 @@ mod tests {
|
||||
// FEAT-015 shipped no production contextual command, so the assertion
|
||||
// below used to exclude nothing. FEAT-018 migrates the utility group;
|
||||
// the remaining non-fixture commands must still use the legacy
|
||||
// concrete-App path. The seven utility entries are asserted separately
|
||||
// by the FEAT-018 public-dispatch and inventory tests (Phase 6).
|
||||
// concrete-App path. The migrated utility entries (the FEAT-018 seven
|
||||
// plus `/dispatch`, which joins the same portable path) are asserted
|
||||
// separately by the FEAT-018 public-dispatch and inventory tests.
|
||||
const FEAT_018_UTILITY: &[&str] = &[
|
||||
"attach",
|
||||
"automation",
|
||||
"dispatch",
|
||||
"jobs",
|
||||
"mcp",
|
||||
"network",
|
||||
|
||||
@@ -29,6 +29,7 @@ mod auto_reasoning;
|
||||
mod automation_manager;
|
||||
mod child_env;
|
||||
mod client;
|
||||
pub mod cloud_dispatch;
|
||||
mod codex_model_cache;
|
||||
mod command_safety;
|
||||
mod commands;
|
||||
|
||||
@@ -432,6 +432,7 @@ pub enum MessageId {
|
||||
GoalContinuationStopped,
|
||||
CmdInitDescription,
|
||||
CmdJobsDescription,
|
||||
CmdDispatchDescription,
|
||||
CmdLinksDescription,
|
||||
CmdLoadDescription,
|
||||
CmdLogoutDescription,
|
||||
@@ -2255,6 +2256,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdAgentDescription,
|
||||
MessageId::CmdInitDescription,
|
||||
MessageId::CmdJobsDescription,
|
||||
MessageId::CmdDispatchDescription,
|
||||
MessageId::CmdLinksDescription,
|
||||
MessageId::CmdLoadDescription,
|
||||
MessageId::CmdLogoutDescription,
|
||||
|
||||
@@ -678,7 +678,15 @@ pub(crate) fn handle_shell_job_action(app: &mut App, action: crate::tui::app::Sh
|
||||
match action {
|
||||
crate::tui::app::ShellJobAction::List => {
|
||||
let jobs = manager.list_jobs_for_session(&active_session_id);
|
||||
add_shell_job_message(app, format_shell_job_list(&jobs));
|
||||
let mut text = format_shell_job_list(&jobs);
|
||||
if let Ok(cloud) =
|
||||
crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.list())
|
||||
&& !cloud.is_empty()
|
||||
{
|
||||
text.push_str("\n\n");
|
||||
text.push_str(&crate::cloud_dispatch::format_job_list(&cloud));
|
||||
}
|
||||
add_shell_job_message(app, text);
|
||||
}
|
||||
crate::tui::app::ShellJobAction::Show { id } => {
|
||||
match manager.inspect_job_for_session(&active_session_id, &id) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Daytona cloud-agent dispatch
|
||||
|
||||
Local `cw` / Codewhale can offload a coding agent to Daytona the way Cursor
|
||||
sends a cloud agent: the remote job raises a branch and is intended to open a
|
||||
PR against an explicit forge. Local stays responsive; spend and push never
|
||||
happen silently.
|
||||
|
||||
## One obvious offload
|
||||
|
||||
```sh
|
||||
codewhale dispatch "open a PR that fixes the flake" --remote github
|
||||
codewhale dispatch --confirm cloud_<id>
|
||||
```
|
||||
|
||||
Same action in the TUI:
|
||||
|
||||
```
|
||||
/dispatch open a PR that fixes the flake --remote github
|
||||
/dispatch confirm cloud_<id>
|
||||
```
|
||||
|
||||
`codewhale cloud-agent` and `/cloud-agent` are aliases. `--confirm` /
|
||||
`/dispatch confirm` is required. A proposal is written first; nothing creates a
|
||||
Daytona sandbox or pushes a branch until that confirmation.
|
||||
|
||||
Cloud jobs are first-class on the existing jobs surface (`kind=cloud`):
|
||||
|
||||
```
|
||||
/jobs list
|
||||
/dispatch list
|
||||
/dispatch show <id>
|
||||
/dispatch cancel <id>
|
||||
codewhale dispatch --list
|
||||
```
|
||||
|
||||
## Remotes
|
||||
|
||||
Forges are explicit: `github`, `cnb`, `gitee`.
|
||||
|
||||
CWC already treats a remote *named* `github` as authoritative GitHub and
|
||||
`origin` as the CNB mirror when that URL is `cnb.cool`. Codewhale uses the same
|
||||
rule:
|
||||
|
||||
| Remote name | URL host | Forge |
|
||||
| --- | --- | --- |
|
||||
| `github` | any | `github` |
|
||||
| `cnb` | any | `cnb` |
|
||||
| `gitee` | any | `gitee` |
|
||||
| `origin` or other | `github.com` | `github` |
|
||||
| `origin` or other | `cnb.cool` | `cnb` |
|
||||
| `origin` or other | `gitee.com` | `gitee` |
|
||||
|
||||
If more than one forge is present, pass `--remote` / `--remote` on `/dispatch`.
|
||||
Do not assume `origin` is GitHub.
|
||||
|
||||
## Enable Daytona (fail-closed)
|
||||
|
||||
This slice does **not** fake a successful remote PR. Without credentials,
|
||||
dispatch refuses and records a `refused` cloud job.
|
||||
|
||||
1. Create an API key at [app.daytona.io/dashboard/keys](https://app.daytona.io/dashboard/keys).
|
||||
2. Export it in the process environment (never commit it, never put it in
|
||||
`models.toml` or `config.toml`):
|
||||
|
||||
```sh
|
||||
export DAYTONA_API_KEY=...
|
||||
# optional
|
||||
export DAYTONA_API_URL=https://app.daytona.io/api
|
||||
```
|
||||
|
||||
3. Or store the same key in the Codewhale secret slot `daytona` (OS keyring /
|
||||
`$CODEWHALE_HOME` secrets file). The CWC alias `CWC_DAYTONA_TOKEN` is also
|
||||
accepted so an already-configured control-plane shell works.
|
||||
|
||||
`daytona` CLI being installed is **not** a credential. `codewhale dispatch
|
||||
--status` / bare `/dispatch` report CLI presence separately.
|
||||
|
||||
## Confirmation and fail-closed rules
|
||||
|
||||
- No `--confirm` / `/dispatch confirm`: write a `proposed` job, exit success,
|
||||
do not call Daytona, do not push.
|
||||
- Confirm + no credentials: write a `refused` job, exit failure, no sandbox.
|
||||
- Confirm + credentials: create a Daytona sandbox labeled with the job id and
|
||||
forge. This slice does **not** claim a GitHub/CNB/Gitee PR URL. A missing
|
||||
forge token fails closed the same way — it does not invent a PR.
|
||||
|
||||
## Leftover
|
||||
|
||||
- Live watch / log tail of a running sandbox.
|
||||
- Cancel that tears down a paid Daytona sandbox.
|
||||
- Auto-decide heuristics (Codewhale may propose; it must not confirm itself).
|
||||
- The remote agent runner that actually raises the branch and opens the PR.
|
||||
@@ -295,6 +295,9 @@ Operate keeps that direct tool surface and its approval, sandbox, shell,
|
||||
ask-rule, and repository protections. Its difference is orchestration emphasis:
|
||||
Codewhale prefers Fleet workers for independent, parallel, background, or
|
||||
long-running work, while small or tightly coupled work can remain in the parent.
|
||||
Heavy work can also be proposed to a Daytona cloud agent with `codewhale
|
||||
dispatch` or `/dispatch` (explicit confirmation; remotes are `github` / `cnb` /
|
||||
`gitee`). See [DAYTONA_CLOUD_DISPATCH.md](DAYTONA_CLOUD_DISPATCH.md).
|
||||
|
||||
For trusted workspaces where you intentionally want actions to proceed without
|
||||
approval prompts, select the Full Access permission posture with `Shift+Tab`.
|
||||
|
||||
Reference in new issue
Block a user