mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
feat(runtime): hunk-level git writes, fork parent links, suggested tasks
Hunks: POST /v1/git/stage, /unstage and /discard take an optional
`patch` for the single path in `paths`. Its file headers must name that
workspace-relative path on both sides; renames, copies, mode changes,
creations, deletions and binary patches are refused, as are hunks whose
bodies are shorter than their @@ counts. The patch goes through a temp
file to `git apply [--cached] [--reverse]` under the existing write lock
and expect preconditions. It is applied from the repository top with
--directory=<workspace prefix>: run inside a subdirectory, git apply
silently skips paths outside it and reports success having written
nothing. Checked by hand in a scratch repo with a subdirectory
workspace: stage, unstage and discard each change exactly the one hunk,
and re-applying a stale hunk fails with git's message.
Forks: ThreadRecord gains parent_thread_id, set by both fork paths and
returned in /v1/threads/summary, so clients can nest forks under the
thread they came from. Older records have none.
Suggestions: the tasks tool gains a `suggest` action (no approval,
starts nothing) returning {title, prompt}; the logged tool call is the
suggestion, which the desktop shows as a Start/Hide card. Registry row
task-suggestions.
Evidence (RUST_MIN_STACK=8388608):
cargo test -p codewhale-tui --lib -- git_routes_drive git_write_preconditions
runtime_api::git fork thread_summary tools::tasks registry
test result: FAILED. 326 passed; 1 failed; 3 ignored
The failure, runtime_api::git::tests::unreadable_file_only_withholds_affected_row,
chmods a file to 0o000 and expects it unreadable; this container runs as
uid 0, which reads it anyway. cargo fmt clean for the touched files.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018whUMPrn9PSoxXwWwX7dsp
This commit is contained in:
8 files changed
+177
-6
No files matched your search
@@ -1075,6 +1075,8 @@ struct ThreadSummary {
|
||||
pending_attention_count: usize,
|
||||
last_read_item_id: Option<String>,
|
||||
unread: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parent_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -3663,6 +3665,7 @@ async fn list_threads_summary(
|
||||
pending_attention_count,
|
||||
last_read_item_id,
|
||||
unread,
|
||||
parent_thread_id: thread.parent_thread_id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
const MAX_COMMIT_MESSAGE_BYTES: usize = 64 * 1024;
|
||||
const MAX_BRANCH_NAME_BYTES: usize = 256;
|
||||
const MAX_PATH_ARGS: usize = 512;
|
||||
const MAX_PATCH_BYTES: usize = 1024 * 1024;
|
||||
/// Per-read bound on working-tree bytes hashed for `files[].rev`. Rows past
|
||||
/// the budget get a stat fingerprint (`s-` token) instead of a content digest
|
||||
/// (`c-` token), so a large untracked tree cannot turn a status poll into
|
||||
@@ -1459,6 +1460,8 @@ pub(super) struct GitPathsRequest {
|
||||
/// Optional preconditions (#6647); absent keeps the unchecked behaviour.
|
||||
#[serde(default)]
|
||||
expect: Option<GitExpect>,
|
||||
#[serde(default)]
|
||||
patch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1894,10 +1897,124 @@ fn literal_path_args(subcommand: &[&str], paths: Vec<String>) -> Vec<String> {
|
||||
args
|
||||
}
|
||||
|
||||
fn validated_patch(patch: &str, path: &str) -> Result<(), ApiError> {
|
||||
let refuse = |why: &str| Err(ApiError::bad_request(format!("patch {why}")));
|
||||
if patch.len() > MAX_PATCH_BYTES {
|
||||
return refuse(&format!("must be at most {MAX_PATCH_BYTES} bytes"));
|
||||
}
|
||||
let header = format!("diff --git a/{path} b/{path}");
|
||||
let old = format!("--- a/{path}");
|
||||
let new = format!("+++ b/{path}");
|
||||
let (mut seen_old, mut seen_new, mut hunks) = (false, false, 0usize);
|
||||
let (mut old_left, mut new_left) = (0usize, 0usize);
|
||||
for line in patch.lines() {
|
||||
if old_left > 0 || new_left > 0 {
|
||||
match line.as_bytes().first() {
|
||||
Some(b' ') | None => {
|
||||
old_left = old_left.saturating_sub(1);
|
||||
new_left = new_left.saturating_sub(1);
|
||||
}
|
||||
Some(b'-') => old_left = old_left.saturating_sub(1),
|
||||
Some(b'+') => new_left = new_left.saturating_sub(1),
|
||||
Some(b'\\') => {}
|
||||
_ => return refuse("has a hunk shorter than its header"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('\\') {
|
||||
continue;
|
||||
}
|
||||
if let Some(range) = line.strip_prefix("@@ ") {
|
||||
if !(seen_old && seen_new) {
|
||||
return refuse(&format!("must start with --- a/{path} and +++ b/{path}"));
|
||||
}
|
||||
let mut counts = range.split_whitespace().take(2).map(|side| {
|
||||
let side = &side[1..];
|
||||
match side.split_once(',') {
|
||||
Some((_, count)) => count.parse::<usize>().ok(),
|
||||
None => side.parse::<usize>().ok().map(|_| 1),
|
||||
}
|
||||
});
|
||||
let (Some(Some(a)), Some(Some(b))) = (counts.next(), counts.next()) else {
|
||||
return refuse("has a malformed @@ line");
|
||||
};
|
||||
(old_left, new_left) = (a, b);
|
||||
hunks += 1;
|
||||
} else if line.starts_with("diff --git ") {
|
||||
if line != header {
|
||||
return refuse(&format!("may change only {path}"));
|
||||
}
|
||||
(seen_old, seen_new) = (false, false);
|
||||
} else if line.starts_with("--- ") {
|
||||
if line != old {
|
||||
return refuse(&format!("may change only {path}, and not create it"));
|
||||
}
|
||||
seen_old = true;
|
||||
} else if line.starts_with("+++ ") {
|
||||
if line != new {
|
||||
return refuse(&format!("may change only {path}, and not delete it"));
|
||||
}
|
||||
seen_new = true;
|
||||
} else if !line.starts_with("index ") {
|
||||
return refuse("may only change lines (no rename, copy, mode or binary changes)");
|
||||
}
|
||||
}
|
||||
if hunks == 0 || old_left > 0 || new_left > 0 {
|
||||
return refuse("needs at least one complete hunk");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn patch_write(
|
||||
state: &RuntimeApiState,
|
||||
request: GitPathsRequest,
|
||||
apply: &[&str],
|
||||
) -> Result<Json<Value>, GitWriteError> {
|
||||
let patch = request.patch.clone().unwrap_or_default();
|
||||
if request.all || request.paths.len() != 1 {
|
||||
return Err(ApiError::bad_request("patch needs exactly one path").into());
|
||||
}
|
||||
let (_, paths, expect) = paths_and_expect(request, false)?;
|
||||
validated_patch(&patch, &paths[0])?;
|
||||
let file = tokio::task::spawn_blocking(move || {
|
||||
use std::io::Write;
|
||||
let mut file = tempfile::NamedTempFile::new()?;
|
||||
file.write_all(patch.as_bytes())?;
|
||||
file.flush()?;
|
||||
Ok::<_, std::io::Error>(file)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("patch staging failed"))?
|
||||
.map_err(|error| ApiError::internal(format!("patch staging failed: {error}")))?;
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
let place = git_read_ok(&workspace, &["rev-parse", "--show-cdup", "--show-prefix"]).await?;
|
||||
let mut place = place.lines();
|
||||
let (cdup, prefix) = (
|
||||
place.next().unwrap_or_default().to_string(),
|
||||
place.next().unwrap_or_default().to_string(),
|
||||
);
|
||||
let mut args = Vec::new();
|
||||
if !cdup.is_empty() {
|
||||
args.extend(["-C".to_string(), cdup]);
|
||||
}
|
||||
args.extend(apply.iter().map(|arg| arg.to_string()));
|
||||
if !prefix.is_empty() {
|
||||
args.push(format!("--directory={prefix}"));
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.push(file.path().to_string_lossy().into_owned());
|
||||
let result = guarded_write(state, expect, args).await;
|
||||
drop(file);
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn git_stage(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitPathsRequest>,
|
||||
) -> Result<Json<Value>, GitWriteError> {
|
||||
if request.patch.is_some() {
|
||||
return patch_write(&state, request, &["apply", "--cached"]).await;
|
||||
}
|
||||
let (all, paths, expect) = paths_and_expect(request, true)?;
|
||||
let args = if all {
|
||||
vec!["add".to_string(), "--all".to_string()]
|
||||
@@ -1911,6 +2028,9 @@ pub(super) async fn git_unstage(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitPathsRequest>,
|
||||
) -> Result<Json<Value>, GitWriteError> {
|
||||
if request.patch.is_some() {
|
||||
return patch_write(&state, request, &["apply", "--cached", "--reverse"]).await;
|
||||
}
|
||||
let (all, paths, expect) = paths_and_expect(request, true)?;
|
||||
let args = if all {
|
||||
// `:/` is pathspec magic for the repository root, so this one form
|
||||
@@ -1930,6 +2050,9 @@ pub(super) async fn git_discard(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitPathsRequest>,
|
||||
) -> Result<Json<Value>, GitWriteError> {
|
||||
if request.patch.is_some() {
|
||||
return patch_write(&state, request, &["apply", "--reverse"]).await;
|
||||
}
|
||||
let (_, paths, expect) = paths_and_expect(request, false)?;
|
||||
guarded_write(&state, expect, literal_path_args(&["checkout"], paths)).await
|
||||
}
|
||||
|
||||
@@ -472,6 +472,7 @@ fn messages_from_thread_detail_batches_tool_results() {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
parent_thread_id: None,
|
||||
title: None,
|
||||
session_id: None,
|
||||
saved_session_checkpoint: None,
|
||||
@@ -696,6 +697,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
parent_thread_id: None,
|
||||
title: None,
|
||||
session_id: None,
|
||||
saved_session_checkpoint: None,
|
||||
|
||||
@@ -815,6 +815,8 @@ pub struct ThreadRecord {
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_thread_id: Option<String>,
|
||||
/// User-set title for the thread. When `None`, consumers fall back to a
|
||||
/// derived title (typically the latest turn's input summary). Added in
|
||||
/// v0.8.10 (#562); old runtime records simply have no `title` and behave
|
||||
@@ -11675,6 +11677,7 @@ impl RuntimeThreadManager {
|
||||
archived: req.archived,
|
||||
system_prompt: req.system_prompt,
|
||||
task_id: req.task_id,
|
||||
parent_thread_id: None,
|
||||
title: None,
|
||||
session_id: None,
|
||||
saved_session_checkpoint: None,
|
||||
@@ -13812,6 +13815,7 @@ impl RuntimeThreadManager {
|
||||
forked.latest_turn_id = None;
|
||||
forked.archived = false;
|
||||
forked.last_read = None;
|
||||
forked.parent_thread_id = Some(source.id.clone());
|
||||
|
||||
let source_turns = self.store.list_turns_for_thread(&source.id)?;
|
||||
// One read for every turn's items — see `prepared_items_for_turns`: a
|
||||
@@ -14322,6 +14326,7 @@ impl RuntimeThreadManager {
|
||||
forked.latest_turn_id = None;
|
||||
forked.archived = false;
|
||||
forked.last_read = None;
|
||||
forked.parent_thread_id = Some(source.id.clone());
|
||||
|
||||
// The fork keeps only the prefix it was cut at, in a document of its
|
||||
// own — see `bind_fork_to_own_session`.
|
||||
|
||||
@@ -2464,6 +2464,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
parent_thread_id: None,
|
||||
title: None,
|
||||
session_id: None,
|
||||
saved_session_checkpoint: None,
|
||||
@@ -18971,6 +18972,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
task_id: None,
|
||||
parent_thread_id: None,
|
||||
title: None,
|
||||
session_id: None,
|
||||
saved_session_checkpoint: None,
|
||||
|
||||
@@ -98,6 +98,7 @@ const ALL_ACTIONS: &[&str] = &[
|
||||
"pr_attempt_list",
|
||||
"pr_attempt_read",
|
||||
"pr_attempt_preflight",
|
||||
"suggest",
|
||||
];
|
||||
|
||||
impl TasksTool {
|
||||
@@ -166,7 +167,7 @@ impl TasksTool {
|
||||
}
|
||||
|
||||
fn action_requires_approval(action: &str) -> bool {
|
||||
!Self::action_is_read(action)
|
||||
!Self::action_is_read(action) && action != "suggest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +212,7 @@ impl ToolSpec for TasksTool {
|
||||
"Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"."
|
||||
}
|
||||
_ => {
|
||||
"Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). Run long shell work with the shell tool (background=true to detach immediately; a foreground command still running after 30s moves to the background by itself)."
|
||||
"Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval), \"suggest\" (offer the person a task as a card they can Start or Hide; starts nothing — never create it yourself after suggesting). Run long shell work with the shell tool (background=true to detach immediately; a foreground command still running after 30s moves to the background by itself)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,11 +234,11 @@ impl ToolSpec for TasksTool {
|
||||
if !self.read_only {
|
||||
properties.insert(
|
||||
"prompt".to_string(),
|
||||
json!({ "type": "string", "description": "Work prompt for the durable task (action=create)." }),
|
||||
json!({ "type": "string", "description": "Work prompt for the durable task (action=create), or the prompt Start would send (action=suggest)." }),
|
||||
);
|
||||
properties.insert(
|
||||
"name".to_string(),
|
||||
json!({ "type": "string", "description": "Short run name shown in queues; omit to derive from the prompt. (action=create)" }),
|
||||
json!({ "type": "string", "description": "Short run name shown in queues; omit to derive from the prompt. (action=create; for action=suggest, the card's title)" }),
|
||||
);
|
||||
properties.insert(
|
||||
"model_provider".to_string(),
|
||||
@@ -397,6 +398,7 @@ impl ToolSpec for TasksTool {
|
||||
"pr_attempt_list" => self.execute_pr_attempt_list(&input, context).await,
|
||||
"pr_attempt_read" => self.execute_pr_attempt_read(&input, context).await,
|
||||
"pr_attempt_preflight" => self.execute_pr_attempt_preflight(&input, context).await,
|
||||
"suggest" => execute_suggest(&input),
|
||||
action => Err(ToolError::invalid_input(format!(
|
||||
"tasks: invalid action `{action}`"
|
||||
))),
|
||||
@@ -404,6 +406,22 @@ impl ToolSpec for TasksTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_suggest(input: &Value) -> Result<ToolResult, ToolError> {
|
||||
let prompt = required_str(input, "prompt")?.trim();
|
||||
let title = optional_str(input, "name")?
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(prompt);
|
||||
if prompt.is_empty() {
|
||||
return Err(ToolError::invalid_input("tasks: suggest needs a prompt"));
|
||||
}
|
||||
ToolResult::json(&json!({
|
||||
"summary": format!("Suggested to the person: {title}. Nothing was started; they choose Start or Hide."),
|
||||
"suggestion": { "title": title, "prompt": prompt },
|
||||
}))
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
|
||||
fn dangerous_gate_block(safety: &SafetyAnalysis) -> Option<ToolResult> {
|
||||
if safety.level != SafetyLevel::Dangerous {
|
||||
return None;
|
||||
|
||||
+11
-2
@@ -1212,7 +1212,9 @@ title is unset, the latest turn's input summary — the displayed title). It
|
||||
does not scan turn or item bodies: `preview` is filled only after a match, so
|
||||
a dashboard keystroke is not a whole-store read per thread. Each item includes
|
||||
`id`, `title`, `preview`, `model`, `mode`, `archived`, `updated_at`,
|
||||
`latest_turn_id`, `latest_turn_status`, plus workspace metadata:
|
||||
`latest_turn_id`, `latest_turn_status`, `parent_thread_id` when the thread
|
||||
was forked from another (absent on threads forked before the field existed),
|
||||
plus workspace metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -2241,7 +2243,14 @@ also how a client sees model-spawned work.
|
||||
`POST /v1/git/unstage` same; `POST /v1/git/discard` `{ "paths": [...] }`
|
||||
(tracked paths only — no `all`, an untracked path fails closed);
|
||||
`POST /v1/git/commit` `{ "message", "all"? }`; stage, unstage, discard
|
||||
and commit also take an optional `expect` (below); `POST /v1/git/push`
|
||||
and commit also take an optional `expect` (below). Stage, unstage and
|
||||
discard also take `patch`: hunks of the one path in `paths`, with file
|
||||
headers `diff --git a/<path> b/<path>`, `--- a/<path>`, `+++ b/<path>`
|
||||
naming that workspace-relative path. Stage applies them to the index,
|
||||
unstage reverses them in the index, discard reverses them in the working
|
||||
tree (`git apply [--cached] [--reverse]`). Renames, copies, mode changes,
|
||||
creations, deletions and binary patches are refused, and a patch that no
|
||||
longer applies fails with git's message and writes nothing. `POST /v1/git/push`
|
||||
`{ "remote"?, "set_upstream"? }` (`remote`, or `origin` when only
|
||||
`set_upstream` is given, must name a configured remote);
|
||||
`POST /v1/git/branch`
|
||||
|
||||
@@ -386,6 +386,15 @@ since = "unreleased"
|
||||
docs = "docs/RUNTIME_API.md"
|
||||
owner = "crates/tui/src/automation_manager.rs"
|
||||
|
||||
[[feature]]
|
||||
id = "task-suggestions"
|
||||
name = "Suggested tasks"
|
||||
summary = "The tasks tool's suggest action offers the person a task as a Start/Hide card; it starts nothing and needs no approval."
|
||||
status = "experimental"
|
||||
surfaces = { desktop = "preview" }
|
||||
since = "unreleased"
|
||||
owner = "crates/tui/src/tools/tasks.rs"
|
||||
|
||||
[[feature]]
|
||||
id = "todos"
|
||||
name = "Todo list"
|
||||
|
||||
Reference in new issue
Block a user