- 11650
let prepared = (|| -> Result<std::path::PathBuf, String> { - 11651
for file in &saved.candidate.files { - 11652
if file.operation != vak_sandbox::CandidateOperation::Upsert || file.path == body.path { - 11653
continue; - 11654
} - 11655
let from = confined_path(&saved.candidate.source_root, &file.path) - 11656
.ok_or("a candidate file is outside its root")?; - 11657
let to = staging.join(&file.path); - 11658
if let Some(parent) = to.parent() { - 11659
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; - 11660
} - 11661
std::fs::copy(&from, &to).map_err(|error| error.to_string())?; - 11662
} - 11663
let out = staging.join(&body.path); - 11664
if let Some(parent) = out.parent() { - 11665
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; - 11666
} - 11667
Ok(out) - 11668
})(); - 11669
let out = match prepared { - 11670
Ok(out) => out, - 11671
Err(error) => { - 11672
let _ = std::fs::remove_dir_all(&staging); - 11673
return refuse(StatusCode::INTERNAL_SERVER_ERROR, error); - 11674
} - 11675
}; - 11676
let Some(draft) = confined_path(&saved.candidate.source_root, &body.path) else { - 11677
let _ = std::fs::remove_dir_all(&staging); - 11678
return StatusCode::FORBIDDEN.into_response(); - 11679
}; - 11680
let narrowed = vak_tools::broker::office_narrow( - 11681
&state.core.tool_worker_exe(), - 11682
&lineage, - 11683
&draft, - 11684
&body.keep, - 11685
&out, - 11686
) - 11687
.await; - 11688
if let Err(error) = narrowed { - 11689
let _ = std::fs::remove_dir_all(&staging); - 11690
return refuse(StatusCode::UNPROCESSABLE_ENTITY, error); - 11691
} - 11692
let frozen_root = sandbox_candidates_root(&state).join(&id); - 11693
let frozen = - 11694
vak_sandbox::freeze_revision_candidate(&id, &staging, &saved.candidate, &frozen_root); - 11695
let _ = std::fs::remove_dir_all(&staging); - 11696
let mut candidate = match frozen { - 11697
Ok(candidate) => candidate, - 11698
Err(error) => return refuse(StatusCode::CONFLICT, error.to_string()), - 11699
}; - 11700
candidate.target_checks = vak_sandbox::default_target_verifiers().plan(&candidate); - 11701
candidate.workspace_checks = planned_workspace_checks(&candidate); - 11702
let draft_checks = vak_tools::broker::verify_targets( - 11703
&state.core.tool_worker_exe(), - 11704
&candidate.source_root, - 11705
&candidate.target_checks, - 11706
) - 11707
.await; - 11708
let candidate_digest = match vak_sandbox::candidate_digest(&candidate) { - 11709
Ok(digest) => digest, - 11710
Err(error) => { - 11711
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 11712
return refuse(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()); - 11713
} - 11714
}; - 11715
let record = vak_sandbox::DurableRecord::Candidate(vak_sandbox::CandidateRecord { - 11716
record_id: format!("candidate-{id}"), - 11717
session_id: saved.session_id.clone(), - 11718
turn_id: saved.turn_id.clone(), - 11719
result_id: saved.result_id.clone(), - 11720
execution_id: saved.execution_id.clone(), - 11721
environment_id: saved.environment_id.clone(), - 11722
candidate_digest, - 11723
candidate, - 11724
verified: true, - 11725
draft_checks, - 11726
updated_at: chrono::Utc::now().to_rfc3339(), - 11727
parent_candidate_id: Some(candidate_id), - 11728
revision_session_id: None, - 11729
narrowed: Some(vak_sandbox::NarrowedDraft { - 11730
path: body.path, - 11731
keep: body.keep, - 11732
}), - 11733
}); - 11734
match vak_sandbox::append_record(&sandbox_records_path(&state), &record) { - 11735
Ok(()) => Json(record).into_response(), - 11736
Err(error) => { - 11737
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 11738
refuse(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) - 11739
} - 11740
} - 11741
} - 11742
- 11743
async fn read_sandbox_candidate_file( - 11744
State(state): State<AppState>, - 11745
Path((session_id, candidate_id)): Path<(String, String)>, - 11746
axum::extract::Query(q): axum::extract::Query<FileQuery>, - 11747
) -> axum::response::Response { - 11748
use axum::response::IntoResponse; - 11749
let bytes = - 11750
match sandbox_candidate_file_bytes(&state, &session_id, &candidate_id, &q.path).await { - 11751
Ok(bytes) => bytes, - 11752
Err(status) => return status.into_response(), - 11753
}; - 11754
let size = bytes.len(); - 11755
let content = String::from_utf8(bytes).ok(); - 11756
Json(serde_json::json!({ "path": q.path, "kind": if content.is_some() { "text" } else { "binary" }, "bytes": size, "content": content, "editable": false })).into_response() - 11757
} - 11758
- 11759
async fn read_sandbox_candidate_file_raw( - 11760
State(state): State<AppState>, - 11761
Path((session_id, candidate_id)): Path<(String, String)>, - 11762
axum::extract::Query(q): axum::extract::Query<FileQuery>, - 11763
) -> axum::response::Response { - 11764
use axum::response::IntoResponse; - 11765
let bytes = - 11766
match sandbox_candidate_file_bytes(&state, &session_id, &candidate_id, &q.path).await { - 11767
Ok(bytes) => bytes, - 11768
Err(status) => return status.into_response(), - 11769
}; - 11770
let headers = [ - 11771
( - 11772
axum::http::header::CONTENT_TYPE, - 11773
raw_mime_for(std::path::Path::new(&q.path)), - 11774
), - 11775
(axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"), - 11776
(axum::http::header::CACHE_CONTROL, "no-store"), - 11777
(axum::http::header::CONTENT_DISPOSITION, "attachment"), - 11778
( - 11779
axum::http::header::CONTENT_SECURITY_POLICY, - 11780
"sandbox; default-src 'none'", - 11781
), - 11782
]; - 11783
(headers, bytes).into_response() - 11784
} - 11785
- 11786
#[derive(Debug, serde::Deserialize)] - 11787
struct CandidateCommentBody { - 11788
text: String, - 11789
#[serde(default)] - 11790
path: Option<String>, - 11791
#[serde(default)] - 11792
line_start: Option<u32>, - 11793
#[serde(default)] - 11794
line_end: Option<u32>, - 11795
/// Where in an Office file the comment points (`Budget!B4`, - 11796
/// `p:1A2B3C4D`, `slide:256/shape:3`), in place of line numbers, which - 11797
/// mean nothing in a package (docs/design/72, F9). - 11798
#[serde(default)] - 11799
anchor: Option<String>, - 11800
#[serde(default)] - 11801
request_id: Option<String>, - 11802
} - 11803
- 11804
async fn list_sandbox_candidate_comments( - 11805
State(state): State<AppState>, - 11806
Path((session_id, candidate_id)): Path<(String, String)>, - 11807
) -> axum::response::Response { - 11808
use axum::response::IntoResponse; - 11809
let records = match vak_sandbox::load_records(&sandbox_records_path(&state)) { - 11810
Ok(records) => records, - 11811
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 11812
}; - 11813
if !records.iter().any(|record| { - 11814
matches!(record, - 11815
vak_sandbox::DurableRecord::Candidate(saved) - 11816
if saved.session_id == session_id && saved.candidate.candidate_id == candidate_id) - 11817
}) { - 11818
return StatusCode::NOT_FOUND.into_response(); - 11819
} - 11820
let mut comments = Vec::new(); - 11821
let mut seen = std::collections::HashSet::new(); - 11822
if let Some(log) = find_session_on_disk(&state.core, &session_id) { - 11823
for (_, timestamp, activity) in log.activities() { - 11824
if activity.kind == vak_session::ActivityKind::CandidateComment - 11825
&& activity.data.get("candidate_id") == Some(&candidate_id) - 11826
{ - 11827
seen.insert(activity.activity_id.clone()); - 11828
comments.push(serde_json::json!({ - 11829
"comment_id": activity.activity_id, - 11830
"actor_id": activity.data.get("actor_id").map(String::as_str).unwrap_or("operator"), - 11831
"actor_name": activity.data.get("actor_name").map(String::as_str).unwrap_or("You"), - 11832
"text": activity.data.get("comment").cloned().unwrap_or_default(), - 11833
"path": activity.data.get("path"), - 11834
"line_start": activity.data.get("line_start").and_then(|value| value.parse::<u32>().ok()), - 11835
"line_end": activity.data.get("line_end").and_then(|value| value.parse::<u32>().ok()), - 11836
"anchor": activity.data.get("anchor"), - 11837
"created_at": timestamp.to_rfc3339(), - 11838
})); - 11839
} - 11840
} - 11841
} - 11842
if let Some(handle) = state.get(&session_id) { - 11843
let buffered = handle - 11844
.activity_buffer - 11845
.lock() - 11846
.unwrap_or_else(std::sync::PoisonError::into_inner); - 11847
for activity in buffered.iter() { - 11848
if activity.kind == vak_session::ActivityKind::CandidateComment - 11849
&& activity.data.get("candidate_id") == Some(&candidate_id) - 11850
&& seen.insert(activity.activity_id.clone()) - 11851
{ - 11852
comments.push(serde_json::json!({ - 11853
"comment_id": activity.activity_id, - 11854
"actor_id": activity.data.get("actor_id").map(String::as_str).unwrap_or("operator"), - 11855
"actor_name": activity.data.get("actor_name").map(String::as_str).unwrap_or("You"), - 11856
"text": activity.data.get("comment").cloned().unwrap_or_default(), - 11857
"path": activity.data.get("path"), - 11858
"line_start": activity.data.get("line_start").and_then(|value| value.parse::<u32>().ok()), - 11859
"line_end": activity.data.get("line_end").and_then(|value| value.parse::<u32>().ok()), - 11860
"anchor": activity.data.get("anchor"), - 11861
"created_at": serde_json::Value::Null, - 11862
})); - 11863
} - 11864
} - 11865
} - 11866
Json(serde_json::json!({ "comments": comments })).into_response() - 11867
} - 11868
- 11869
async fn comment_on_sandbox_candidate( - 11870
State(state): State<AppState>, - 11871
Path((session_id, candidate_id)): Path<(String, String)>, - 11872
axum::Extension(principal): axum::Extension<AuthenticatedPrincipal>, - 11873
Json(body): Json<CandidateCommentBody>, - 11874
) -> axum::response::Response { - 11875
use axum::response::IntoResponse; - 11876
let text = body.text.trim(); - 11877
if text.is_empty() || text.len() > 32 * 1024 { - 11878
return StatusCode::BAD_REQUEST.into_response(); - 11879
} - 11880
if body.line_start.is_some_and(|line| line == 0) - 11881
|| body.line_end.is_some_and(|line| line == 0) - 11882
|| (body.line_end.is_some() && body.line_start.is_none()) - 11883
|| matches!((body.line_start, body.line_end), (Some(start), Some(end)) if end < start) - 11884
{ - 11885
return StatusCode::BAD_REQUEST.into_response(); - 11886
} - 11887
let records = match vak_sandbox::load_records(&sandbox_records_path(&state)) { - 11888
Ok(records) => records, - 11889
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 11890
}; - 11891
let Some(saved) = records.iter().rev().find_map(|record| match record { - 11892
vak_sandbox::DurableRecord::Candidate(saved) - 11893
if saved.session_id == session_id && saved.candidate.candidate_id == candidate_id => - 11894
{ - 11895
Some(saved) - 11896
} - 11897
_ => None, - 11898
}) else { - 11899
return StatusCode::NOT_FOUND.into_response(); - 11900
}; - 11901
if let AuthenticatedPrincipal::Participant(participant) = &principal - 11902
&& conversation_audience(&state, &session_id).as_deref() - 11903
!= Some(participant.audience_id.as_str()) - 11904
{ - 11905
return StatusCode::FORBIDDEN.into_response(); - 11906
} - 11907
let path = body - 11908
.path - 11909
.as_deref() - 11910
.map(str::trim) - 11911
.filter(|path| !path.is_empty()); - 11912
if path.is_some_and(|path| !saved.candidate.files.iter().any(|file| file.path == path)) { - 11913
return (StatusCode::BAD_REQUEST, "comment path is not in candidate").into_response(); - 11914
} - 11915
if (body.line_start.is_some() || body.line_end.is_some()) && path.is_none() { - 11916
return ( - 11917
StatusCode::BAD_REQUEST, - 11918
"line anchor requires a candidate path", - 11919
) - 11920
.into_response(); - 11921
} - 11922
let anchor = body - 11923
.anchor - 11924
.as_deref() - 11925
.map(str::trim) - 11926
.filter(|anchor| !anchor.is_empty()); - 11927
let office = path.is_some_and(vak_ooxml::is_openxml_path); - 11928
if office && body.line_start.is_some() { - 11929
return ( - 11930
StatusCode::BAD_REQUEST, - 11931
"line numbers mean nothing in an Office file; point at a cell, paragraph or slide with anchor", - 11932
) - 11933
.into_response(); - 11934
} - 11935
if let Some(anchor) = anchor { - 11936
if !office { - 11937
return ( - 11938
StatusCode::BAD_REQUEST, - 11939
"anchor requires an Office file path; use line numbers for text files", - 11940
) - 11941
.into_response(); - 11942
} - 11943
if !vak_ooxml::is_anchor(anchor) { - 11944
return ( - 11945
StatusCode::BAD_REQUEST, - 11946
"anchor is not a cell, paragraph, slide or shape anchor", - 11947
) - 11948
.into_response(); - 11949
} - 11950
} - 11951
let Some(handle) = state.get(&session_id) else { - 11952
return StatusCode::NOT_FOUND.into_response(); - 11953
}; - 11954
let request_id = body - 11955
.request_id - 11956
.as_deref() - 11957
.map(str::trim) - 11958
.filter(|value| !value.is_empty()) - 11959
.map(ToOwned::to_owned) - 11960
.unwrap_or_else(|| format!("candidate-comment-{}", uuid::Uuid::now_v7())); - 11961
let request_id = match &principal { - 11962
AuthenticatedPrincipal::Operator => request_id, - 11963
AuthenticatedPrincipal::Participant(_) => { - 11964
format!("candidate-comment-{}", uuid::Uuid::now_v7()) - 11965
} - 11966
}; - 11967
let mut data = std::collections::BTreeMap::new(); - 11968
data.insert("request_id".into(), request_id.clone()); - 11969
match &principal { - 11970
AuthenticatedPrincipal::Operator => { - 11971
data.insert("actor_id".into(), "operator".into()); - 11972
data.insert("actor_name".into(), "You".into()); - 11973
} - 11974
AuthenticatedPrincipal::Participant(participant) => { - 11975
data.insert("actor_id".into(), participant.principal_id.clone()); - 11976
data.insert("actor_name".into(), participant.display_name.clone()); - 11977
data.insert("grant_id".into(), participant.grant_id.clone()); - 11978
} - 11979
} - 11980
data.insert("candidate_id".into(), candidate_id.clone()); - 11981
data.insert("candidate_digest".into(), saved.candidate_digest.clone()); - 11982
data.insert("result_id".into(), saved.result_id.clone()); - 11983
data.insert("execution_id".into(), saved.execution_id.clone()); - 11984
if let Some(path) = path { - 11985
data.insert("path".into(), path.to_string()); - 11986
} - 11987
if let Some(line) = body.line_start { - 11988
data.insert("line_start".into(), line.to_string()); - 11989
} - 11990
if let Some(line) = body.line_end { - 11991
data.insert("line_end".into(), line.to_string()); - 11992
} - 11993
if let Some(anchor) = anchor { - 11994
data.insert("anchor".into(), anchor.to_string()); - 11995
} - 11996
data.insert("comment".into(), text.to_string()); - 11997
let comment = vak_session::ActivityRecord { - 11998
activity_id: format!("comment-{request_id}"), - 11999
turn: None, - 12000
kind: vak_session::ActivityKind::CandidateComment, - 12001
status: vak_session::ActivityStatus::Succeeded, - 12002
label: "Candidate comment".into(), - 12003
detail: None, - 12004
data, - 12005
}; - 12006
// A comment receipt must not claim success if its append-only record failed. - 12007
let recorded = match handle.session.lock() { - 12008
Ok(mut session) => match session.as_mut() { - 12009
Some(session) if session.is_read_only() => { - 12010
let path = session.path().to_path_buf(); - 12011
vak_session::SessionLog::open(path) - 12012
.and_then(|mut writable| writable.append_activity(comment)) - 12013
.is_ok() - 12014
} - 12015
Some(session) => session.append_activity(comment).is_ok(), - 12016
None => handle - 12017
.activity_buffer - 12018
.lock() - 12019
.map(|mut buffer| buffer.push(comment)) - 12020
.is_ok(), - 12021
}, - 12022
Err(_) => false, - 12023
}; - 12024
if !recorded { - 12025
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 12026
} - 12027
let _ = handle.coworking_comments_tx.send(()); - 12028
if matches!(principal, AuthenticatedPrincipal::Participant(_)) { - 12029
return ( - 12030
StatusCode::CREATED, - 12031
Json(serde_json::json!({ "comment_id": format!("comment-{request_id}"), "intervention": false })), - 12032
) - 12033
.into_response(); - 12034
} - 12035
( - 12036
StatusCode::CREATED, - 12037
Json(serde_json::json!({ "comment_id": format!("comment-{request_id}"), "intervention": false })), - 12038
) - 12039
.into_response() - 12040
} - 12041
- 12042
fn append_candidate_revision_activity( - 12043
handle: &Arc<SessionHandle>, - 12044
revision_id: &str, - 12045
candidate_id: &str, - 12046
child_session_id: &str, - 12047
status: vak_session::ActivityStatus, - 12048
detail: Option<String>, - 12049
) -> bool { - 12050
let activity = vak_session::ActivityRecord { - 12051
activity_id: format!( - 12052
"candidate-revision-{revision_id}-{}", - 12053
match status { - 12054
vak_session::ActivityStatus::Running => "started", - 12055
_ => "finished", - 12056
} - 12057
), - 12058
turn: None, - 12059
kind: vak_session::ActivityKind::CandidateRevision, - 12060
status, - 12061
label: "Draft revision".into(), - 12062
detail, - 12063
data: std::collections::BTreeMap::from([ - 12064
("revision_id".into(), revision_id.into()), - 12065
("candidate_id".into(), candidate_id.into()), - 12066
("child_session_id".into(), child_session_id.into()), - 12067
]), - 12068
}; - 12069
match handle.session.lock() { - 12070
Ok(mut session) => match session.as_mut() { - 12071
Some(session) if session.is_read_only() => { - 12072
vak_session::SessionLog::open(session.path().to_path_buf()) - 12073
.and_then(|mut writable| writable.append_activity(activity)) - 12074
.is_ok() - 12075
} - 12076
Some(session) => session.append_activity(activity).is_ok(), - 12077
None => handle - 12078
.activity_buffer - 12079
.lock() - 12080
.map(|mut buffer| buffer.push(activity)) - 12081
.is_ok(), - 12082
}, - 12083
Err(_) => false, - 12084
} - 12085
} - 12086
- 12087
/// Run a saved-draft revision in a fresh child Core rooted in verified copy - 12088
/// bytes. Its worker has no write path to the owning workspace, even when the - 12089
/// owner has FullAccess. A new candidate is published only after a completed - 12090
/// Agent turn changes the task copy; an Office file changes through the - 12091
/// draft `office_apply` delivered, which takes the file's place in the copy. - 12092
async fn dispatch_candidate_revision( - 12093
state: AppState, - 12094
saved: vak_sandbox::CandidateRecord, - 12095
comment_id: String, - 12096
revision_prompt: String, - 12097
) -> axum::response::Response { - 12098
use axum::response::IntoResponse; - 12099
let Some(parent) = state.get(&saved.session_id) else { - 12100
return StatusCode::NOT_FOUND.into_response(); - 12101
}; - 12102
if parent.core.effective_permission_mode() == vak_config::PermissionMode::ReadOnly { - 12103
return ( - 12104
StatusCode::CONFLICT, - 12105
"Draft editing is disabled in read-only mode", - 12106
) - 12107
.into_response(); - 12108
} - 12109
let Some(agent_identity) = find_session_on_disk(&state.core, &saved.session_id) - 12110
.and_then(|log| log.header().and_then(|header| header.agent.clone())) - 12111
else { - 12112
return ( - 12113
StatusCode::CONFLICT, - 12114
"Agent identity for this conversation is unavailable", - 12115
) - 12116
.into_response(); - 12117
}; - 12118
let records_path = sandbox_records_path(&state); - 12119
let records = match vak_sandbox::load_records(&records_path) { - 12120
Ok(records) => records, - 12121
Err(error) => { - 12122
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(); - 12123
} - 12124
}; - 12125
let admission = format!("candidate-revision:{comment_id}"); - 12126
if let Some(previous) = records.iter().rev().find_map(|record| match record { - 12127
vak_sandbox::DurableRecord::CandidateRevision(record) - 12128
if record.session_id == saved.session_id - 12129
&& record.parent_candidate_id == saved.candidate.candidate_id - 12130
&& record.comment_id == comment_id => - 12131
{ - 12132
Some(record) - 12133
} - 12134
_ => None, - 12135
}) { - 12136
match previous.status { - 12137
vak_sandbox::CandidateRevisionStatus::Completed => { - 12138
return Json( - 12139
serde_json::json!({"request_id": previous.revision_id, "state": "completed"}), - 12140
) - 12141
.into_response(); - 12142
} - 12143
vak_sandbox::CandidateRevisionStatus::Running => { - 12144
if records.iter().any(|record| matches!(record, - 12145
vak_sandbox::DurableRecord::Candidate(candidate) - 12146
if candidate.revision_session_id.as_deref() == Some(previous.child_session_id.as_str()))) { - 12147
return Json(serde_json::json!({"request_id": previous.revision_id, "state": "completed"})).into_response(); - 12148
} - 12149
let active = parent - 12150
.admissions - 12151
.lock() - 12152
.is_ok_and(|admissions| admissions.contains(&admission)) - 12153
|| state.get(&previous.child_session_id).is_some_and(|handle| { - 12154
handle.session.lock().is_ok_and(|session| session.is_none()) - 12155
}); - 12156
if active { - 12157
return (StatusCode::ACCEPTED, Json(serde_json::json!({"request_id": previous.revision_id, "state": "running"}))).into_response(); - 12158
} - 12159
let _ = vak_sandbox::append_record(&records_path, &vak_sandbox::DurableRecord::CandidateRevision(vak_sandbox::CandidateRevisionRecord { - 12160
record_id: format!("revision-{}-interrupted", previous.revision_id), - 12161
status: vak_sandbox::CandidateRevisionStatus::Failed, - 12162
detail: Some("The isolated revision was interrupted before a new candidate was saved".into()), - 12163
updated_at: chrono::Utc::now().to_rfc3339(), - 12164
..previous.clone() - 12165
})); - 12166
} - 12167
_ => {} - 12168
} - 12169
} - 12170
let admitted = match parent.admissions.lock() { - 12171
Ok(mut admissions) => admissions.insert(admission.clone()), - 12172
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 12173
}; - 12174
if !admitted { - 12175
return (StatusCode::CONFLICT, "Revision request already starting").into_response(); - 12176
} - 12177
let revision_id = uuid::Uuid::now_v7().to_string(); - 12178
let task_root = state - 12179
.core - 12180
.sessions_home() - 12181
.join("sandbox") - 12182
.join("revisions") - 12183
.join(&revision_id) - 12184
.join("work"); - 12185
let setup = (|| -> Result<(), String> { - 12186
std::fs::create_dir_all(task_root.parent().ok_or("revision root unavailable")?) - 12187
.map_err(|error| error.to_string())?; - 12188
vak_sandbox::prepare_revision_copy(&saved.candidate, &task_root) - 12189
.map_err(|error| error.to_string()) - 12190
})(); - 12191
if let Err(error) = setup { - 12192
if let Ok(mut admissions) = parent.admissions.lock() { - 12193
admissions.remove(&admission); - 12194
} - 12195
return (StatusCode::CONFLICT, error).into_response(); - 12196
} - 12197
// An Office file the draft creates is a new document: the revision - 12198
// edits it clean, as the draft was made, though the copy holds it. - 12199
let new_documents: Vec<String> = saved - 12200
.candidate - 12201
.files - 12202
.iter() - 12203
.filter(|file| { - 12204
file.operation != vak_sandbox::CandidateOperation::Delete - 12205
&& vak_ooxml::is_openxml_path(&file.path) - 12206
&& !saved.candidate.destination_root.join(&file.path).is_file() - 12207
}) - 12208
.map(|file| file.path.replace('\\', "/")) - 12209
.collect(); - 12210
let child_core = match Core::new_with_trust(task_root.clone(), false) { - 12211
Ok(core) => core - 12212
.with_agent_identity(Some(agent_identity)) - 12213
.with_surface(vak_core::Surface::Background) - 12214
.with_approver_answerable(false) - 12215
.with_task_copy_boundary() - 12216
.with_new_documents(new_documents), - 12217
Err(error) => { - 12218
if let Ok(mut admissions) = parent.admissions.lock() { - 12219
admissions.remove(&admission); - 12220
} - 12221
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(); - 12222
} - 12223
}; - 12224
child_core.set_sessions_home(state.core.shared_data_home()); - 12225
child_core.set_tool_worker_exe(parent.core.tool_worker_exe()); - 12226
child_core.set_permission_mode(vak_config::PermissionMode::WorkspaceWrite); - 12227
child_core.set_route( - 12228
parent.core.effective_provider(), - 12229
parent.core.effective_model(), - 12230
); - 12231
match parent.core.provider() { - 12232
Ok(provider) => child_core.set_provider_instance(provider), - 12233
Err(error) => { - 12234
if let Ok(mut admissions) = parent.admissions.lock() { - 12235
admissions.remove(&admission); - 12236
} - 12237
return (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response(); - 12238
} - 12239
} - 12240
let child_log = match child_core.start_session().await { - 12241
Ok(log) => log, - 12242
Err(error) => { - 12243
if let Ok(mut admissions) = parent.admissions.lock() { - 12244
admissions.remove(&admission); - 12245
} - 12246
return (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response(); - 12247
} - 12248
}; - 12249
let Some(child_session_id) = child_log.header().map(|header| header.session_id.clone()) else { - 12250
if let Ok(mut admissions) = parent.admissions.lock() { - 12251
admissions.remove(&admission); - 12252
} - 12253
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 12254
}; - 12255
let child = register_handle( - 12256
&state, - 12257
child_session_id.clone(), - 12258
child_log, - 12259
task_root.clone(), - 12260
child_core.clone(), - 12261
); - 12262
let running = vak_sandbox::CandidateRevisionRecord { - 12263
record_id: format!("revision-{revision_id}-running"), - 12264
revision_id: revision_id.clone(), - 12265
session_id: saved.session_id.clone(), - 12266
parent_candidate_id: saved.candidate.candidate_id.clone(), - 12267
comment_id: comment_id.clone(), - 12268
child_session_id: child_session_id.clone(), - 12269
task_root: task_root.clone(), - 12270
status: vak_sandbox::CandidateRevisionStatus::Running, - 12271
candidate_id: None, - 12272
detail: None, - 12273
updated_at: chrono::Utc::now().to_rfc3339(), - 12274
}; - 12275
if let Err(error) = vak_sandbox::append_record( - 12276
&records_path, - 12277
&vak_sandbox::DurableRecord::CandidateRevision(running.clone()), - 12278
) { - 12279
if let Ok(mut admissions) = parent.admissions.lock() { - 12280
admissions.remove(&admission); - 12281
} - 12282
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(); - 12283
} - 12284
if !append_candidate_revision_activity( - 12285
&parent, - 12286
&revision_id, - 12287
&saved.candidate.candidate_id, - 12288
&child_session_id, - 12289
vak_session::ActivityStatus::Running, - 12290
None, - 12291
) { - 12292
// The durable revision record survives; a failed conversation append - 12293
// must not launch work that the conversation cannot account for. - 12294
if let Ok(mut admissions) = parent.admissions.lock() { - 12295
admissions.remove(&admission); - 12296
} - 12297
let _ = vak_sandbox::append_record( - 12298
&records_path, - 12299
&vak_sandbox::DurableRecord::CandidateRevision(vak_sandbox::CandidateRevisionRecord { - 12300
record_id: format!("revision-{revision_id}-failed"), - 12301
status: vak_sandbox::CandidateRevisionStatus::Failed, - 12302
detail: Some("Could not record revision start in the conversation".into()), - 12303
updated_at: chrono::Utc::now().to_rfc3339(), - 12304
..running - 12305
}), - 12306
); - 12307
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 12308
} - 12309
let prompt = format!( - 12310
"Revise the saved draft in this isolated working copy. {revision_prompt}. The draft's files are: {}. Change them under their own names: what you change becomes the next version of this draft, which the person reviews and accepts before their workspace changes. Never save a revised file under a new name; add a file only when the change needs a new one.", - 12311
saved - 12312
.candidate - 12313
.files - 12314
.iter() - 12315
.map(|file| file.path.as_str()) - 12316
.collect::<Vec<_>>() - 12317
.join(", ") - 12318
); - 12319
let reply_id = revision_id.clone(); - 12320
tokio::spawn(async move { - 12321
let taken = child - 12322
.session - 12323
.lock() - 12324
.ok() - 12325
.and_then(|mut session| session.take()); - 12326
let outcome = if let Some(log) = taken { - 12327
child_core - 12328
.run_turn_with( - 12329
log, - 12330
&prompt, - 12331
child - 12332
.cancel - 12333
.lock() - 12334
.map(|cancel| cancel.clone()) - 12335
.unwrap_or_else(|_| CancellationToken::new()), - 12336
Some(Arc::new(vak_agent::AutoDeny)), - 12337
None, - 12338
None, - 12339
mpsc_to_broadcast(child.events_tx.clone()), - 12340
) - 12341
.await - 12342
} else { - 12343
Err(vak_core::CoreError::InvalidConfig( - 12344
"revision session unavailable".into(), - 12345
)) - 12346
}; - 12347
let detail: Option<String>; - 12348
let mut new_candidate_id = None; - 12349
let completed = match outcome { - 12350
Ok((vak_agent::TurnOutcome::Completed { response }, log)) => { - 12351
let drafts = revision_office_drafts(&log, &task_root); - 12352
if let Ok(mut slot) = child.session.lock() { - 12353
*slot = Some(log); - 12354
} - 12355
let id = uuid::Uuid::now_v7().to_string(); - 12356
let frozen_root = sandbox_candidates_root(&state).join(&id); - 12357
match vak_sandbox::adopt_revision_drafts(&task_root, &drafts).and_then(|()| { - 12358
vak_sandbox::freeze_revision_candidate( - 12359
&id, - 12360
&task_root, - 12361
&saved.candidate, - 12362
&frozen_root, - 12363
) - 12364
}) { - 12365
Ok(mut candidate) => { - 12366
candidate.target_checks = - 12367
vak_sandbox::default_target_verifiers().plan(&candidate); - 12368
candidate.workspace_checks = planned_workspace_checks(&candidate); - 12369
let draft_checks = vak_tools::broker::verify_targets( - 12370
&state.core.tool_worker_exe(), - 12371
&candidate.source_root, - 12372
&candidate.target_checks, - 12373
) - 12374
.await; - 12375
match vak_sandbox::candidate_digest(&candidate) { - 12376
Ok(candidate_digest) => { - 12377
let record = vak_sandbox::CandidateRecord { - 12378
record_id: format!("candidate-{id}"), - 12379
session_id: saved.session_id.clone(), - 12380
turn_id: saved.turn_id.clone(), - 12381
result_id: saved.result_id.clone(), - 12382
execution_id: saved.execution_id.clone(), - 12383
environment_id: revision_id.clone(), - 12384
candidate_digest, - 12385
candidate, - 12386
verified: true, - 12387
draft_checks, - 12388
updated_at: chrono::Utc::now().to_rfc3339(), - 12389
parent_candidate_id: Some(saved.candidate.candidate_id.clone()), - 12390
revision_session_id: Some(child_session_id.clone()), - 12391
narrowed: None, - 12392
}; - 12393
match vak_sandbox::append_record( - 12394
&records_path, - 12395
&vak_sandbox::DurableRecord::Candidate(record), - 12396
) { - 12397
Ok(()) => { - 12398
let answer = response.text_content(); - 12399
detail = Some(if answer.trim().is_empty() { - 12400
format!("New draft version {id} is ready for review") - 12401
} else { - 12402
format!( - 12403
"New draft version {id} is ready for review. {answer}" - 12404
) - 12405
}); - 12406
new_candidate_id = Some(id); - 12407
true - 12408
} - 12409
Err(error) => { - 12410
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 12411
detail = Some(error.to_string()); - 12412
false - 12413
} - 12414
} - 12415
} - 12416
Err(error) => { - 12417
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 12418
detail = Some(error.to_string()); - 12419
false - 12420
} - 12421
} - 12422
} - 12423
Err(error) => { - 12424
detail = Some(error.to_string()); - 12425
false - 12426
} - 12427
} - 12428
} - 12429
Ok((outcome, log)) => { - 12430
if let Ok(mut slot) = child.session.lock() { - 12431
*slot = Some(log); - 12432
} - 12433
detail = Some(format!( - 12434
"Agent revision ended without a completed result: {outcome:?}" - 12435
)); - 12436
false - 12437
} - 12438
Err(error) => { - 12439
detail = Some(error.to_string()); - 12440
false - 12441
} - 12442
}; - 12443
let finished = vak_sandbox::CandidateRevisionRecord { - 12444
record_id: format!("revision-{revision_id}-finished"), - 12445
status: if completed { - 12446
vak_sandbox::CandidateRevisionStatus::Completed - 12447
} else { - 12448
vak_sandbox::CandidateRevisionStatus::Failed - 12449
}, - 12450
candidate_id: new_candidate_id.clone(), - 12451
detail: detail.clone(), - 12452
updated_at: chrono::Utc::now().to_rfc3339(), - 12453
..running - 12454
}; - 12455
let _ = vak_sandbox::append_record( - 12456
&records_path, - 12457
&vak_sandbox::DurableRecord::CandidateRevision(finished), - 12458
); - 12459
let _ = append_candidate_revision_activity( - 12460
&parent, - 12461
&revision_id, - 12462
&saved.candidate.candidate_id, - 12463
&child_session_id, - 12464
if completed { - 12465
vak_session::ActivityStatus::Succeeded - 12466
} else { - 12467
vak_session::ActivityStatus::Failed - 12468
}, - 12469
detail, - 12470
); - 12471
let _ = parent.coworking_comments_tx.send(()); - 12472
if let Ok(mut admissions) = parent.admissions.lock() { - 12473
admissions.remove(&admission); - 12474
} - 12475
}); - 12476
( - 12477
StatusCode::ACCEPTED, - 12478
Json(serde_json::json!({"request_id": reply_id, "state": "running"})), - 12479
) - 12480
.into_response() - 12481
} - 12482
- 12483
/// Owner decision: make one saved human comment an Agent revision request. - 12484
/// The comment itself stays feedback until this explicit control action. - 12485
async fn request_revision_from_candidate_comment( - 12486
State(state): State<AppState>, - 12487
Path((session_id, candidate_id, comment_id)): Path<(String, String, String)>, - 12488
) -> axum::response::Response { - 12489
use axum::response::IntoResponse; - 12490
let records = match vak_sandbox::load_records(&sandbox_records_path(&state)) { - 12491
Ok(records) => records, - 12492
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 12493
}; - 12494
let Some(saved) = records.iter().rev().find_map(|record| match record { - 12495
vak_sandbox::DurableRecord::Candidate(saved) - 12496
if saved.session_id == session_id && saved.candidate.candidate_id == candidate_id => - 12497
{ - 12498
Some(saved) - 12499
} - 12500
_ => None, - 12501
}) else { - 12502
return StatusCode::NOT_FOUND.into_response(); - 12503
}; - 12504
let from_log = find_session_on_disk(&state.core, &session_id).and_then(|log| { - 12505
log.activities().into_iter().find_map(|(_, _, activity)| { - 12506
(activity.activity_id == comment_id - 12507
&& activity.kind == vak_session::ActivityKind::CandidateComment - 12508
&& activity.data.get("candidate_id") == Some(&candidate_id)) - 12509
.then_some(activity) - 12510
}) - 12511
}); - 12512
let comment = from_log.or_else(|| { - 12513
state.get(&session_id).and_then(|handle| { - 12514
handle.activity_buffer.lock().ok().and_then(|buffer| { - 12515
buffer - 12516
.iter() - 12517
.find(|activity| { - 12518
activity.activity_id == comment_id - 12519
&& activity.kind == vak_session::ActivityKind::CandidateComment - 12520
&& activity.data.get("candidate_id") == Some(&candidate_id) - 12521
}) - 12522
.cloned() - 12523
}) - 12524
}) - 12525
}); - 12526
let Some(comment) = comment else { - 12527
return StatusCode::NOT_FOUND.into_response(); - 12528
}; - 12529
if comment.data.get("candidate_digest") != Some(&saved.candidate_digest) { - 12530
return StatusCode::CONFLICT.into_response(); - 12531
} - 12532
let Some(body) = comment.data.get("comment") else { - 12533
return StatusCode::CONFLICT.into_response(); - 12534
}; - 12535
let location = comment_location(&comment.data); - 12536
dispatch_candidate_revision( - 12537
state, - 12538
saved.clone(), - 12539
comment_id.clone(), - 12540
format!("Revise candidate {candidate_id} for result {}{location}. Owner selected comment {comment_id} by {} as feedback: {body}", saved.result_id, comment.data.get("actor_name").map(String::as_str).unwrap_or("a participant")), - 12541
).await - 12542
} - 12543
- 12544
/// Where a candidate comment points, as the revision request states it: an - 12545
/// Office anchor (`Budget!B4`) or a line range, after the file. - 12546
fn comment_location(data: &std::collections::BTreeMap<String, String>) -> String { - 12547
match ( - 12548
data.get("path"), - 12549
data.get("anchor"), - 12550
data.get("line_start"), - 12551
data.get("line_end"), - 12552
) { - 12553
(Some(path), Some(anchor), _, _) => format!(" file {path}, at {anchor}"), - 12554
(Some(path), None, Some(start), Some(end)) => format!(" file {path}, lines {start}-{end}"), - 12555
(Some(path), None, Some(start), None) => format!(" file {path}, line {start}"), - 12556
(Some(path), None, None, _) => format!(" file {path}"), - 12557
_ => String::new(), - 12558
} - 12559
} - 12560
- 12561
#[derive(Debug, serde::Deserialize)] - 12562
struct SandboxPromotionBody { - 12563
candidate_id: String, - 12564
files: Vec<String>, - 12565
} - 12566
- 12567
async fn promote_sandbox_candidate( - 12568
State(state): State<AppState>, - 12569
Path(session_id): Path<String>, - 12570
Json(body): Json<SandboxPromotionBody>, - 12571
) -> axum::response::Response { - 12572
use axum::response::IntoResponse; - 12573
if body.files.is_empty() - 12574
|| body - 12575
.files - 12576
.iter() - 12577
.collect::<std::collections::HashSet<_>>() - 12578
.len() - 12579
!= body.files.len() - 12580
{ - 12581
return ( - 12582
StatusCode::BAD_REQUEST, - 12583
"select one or more distinct candidate files", - 12584
) - 12585
.into_response(); - 12586
} - 12587
let records = match vak_sandbox::load_records(&sandbox_records_path(&state)) { - 12588
Ok(records) => records, - 12589
Err(error) => { - 12590
return ( - 12591
StatusCode::INTERNAL_SERVER_ERROR, - 12592
Json(serde_json::json!({ "error": error.to_string() })), - 12593
) - 12594
.into_response(); - 12595
} - 12596
}; - 12597
if records.iter().any(|record| matches!(record, vak_sandbox::DurableRecord::Promotion(promoted) if promoted.candidate_id == body.candidate_id)) { - 12598
return (StatusCode::CONFLICT, "candidate already applied").into_response(); - 12599
} - 12600
let Some(saved) = records.iter().rev().find_map(|record| match record { - 12601
vak_sandbox::DurableRecord::Candidate(saved) - 12602
if saved.candidate.candidate_id == body.candidate_id - 12603
&& saved.session_id == session_id => - 12604
{ - 12605
Some(saved.clone()) - 12606
} - 12607
_ => None, - 12608
}) else { - 12609
return (StatusCode::NOT_FOUND, "candidate not found").into_response(); - 12610
}; - 12611
let mut candidate = saved.candidate.clone(); - 12612
if body - 12613
.files - 12614
.iter() - 12615
.any(|path| !candidate.files.iter().any(|file| &file.path == path)) - 12616
{ - 12617
return (StatusCode::BAD_REQUEST, "selected file is not in candidate").into_response(); - 12618
} - 12619
candidate - 12620
.files - 12621
.retain(|file| body.files.contains(&file.path)); - 12622
candidate - 12623
.target_checks - 12624
.retain(|check| body.files.contains(&check.path)); - 12625
// Compare canonical paths: macOS temporary directories can be addressed - 12626
// through `/var` or `/private/var`, which are the same workspace but do - 12627
// not satisfy lexical `starts_with` checks. - 12628
let workspace = - 12629
std::fs::canonicalize(state.core.cwd()).unwrap_or_else(|_| state.core.cwd().to_path_buf()); - 12630
let source_root = std::fs::canonicalize(&candidate.source_root) - 12631
.unwrap_or_else(|_| candidate.source_root.clone()); - 12632
let destination_root = std::fs::canonicalize(&candidate.destination_root) - 12633
.unwrap_or_else(|_| candidate.destination_root.clone()); - 12634
let frozen_root = sandbox_candidates_root(&state).join(&body.candidate_id); - 12635
let source_ok = - 12636
std::fs::canonicalize(&frozen_root).is_ok_and(|expected| source_root == expected); - 12637
let destination_ok = destination_root == workspace; - 12638
if !source_ok || !destination_ok { - 12639
return ( - 12640
StatusCode::FORBIDDEN, - 12641
"candidate roots must remain inside the current workspace", - 12642
) - 12643
.into_response(); - 12644
} - 12645
let promotion_root = sandbox_promotions_root(&state); - 12646
let selected_workspace_checks = planned_workspace_checks(&candidate); - 12647
let applied_root = candidate.destination_root.clone(); - 12648
let planned_checks = candidate.target_checks.clone(); - 12649
let mut receipt = match tokio::task::spawn_blocking(move || {
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.