- 11464
} - 11465
if let Err(status) = - 11466
sandbox_candidate_file_bytes(&state, &session_id, &candidate_id, &q.path).await - 11467
{ - 11468
return status.into_response(); - 11469
} - 11470
let saved = match saved_candidate(&state, &session_id, &candidate_id) { - 11471
Ok(saved) => saved, - 11472
Err(status) => return status.into_response(), - 11473
}; - 11474
let candidate = &saved.candidate; - 11475
let (Some(draft), Some(current)) = ( - 11476
confined_path(&candidate.source_root, &q.path), - 11477
confined_path(&candidate.destination_root, &q.path), - 11478
) else { - 11479
return StatusCode::FORBIDDEN.into_response(); - 11480
}; - 11481
let (lineage, whole_reason) = if let Some(narrowed) = &saved.narrowed { - 11482
( - 11483
None, - 11484
Some(format!( - 11485
"This version keeps {} of the draft's changes. To choose differently, go back to the full draft.", - 11486
narrowed.keep.len() - 11487
)), - 11488
) - 11489
} else if saved.revision_session_id.is_some() { - 11490
( - 11491
None, - 11492
Some( - 11493
"This version was made by a revision, so it is accepted or rejected whole." - 11494
.to_string(), - 11495
), - 11496
) - 11497
} else { - 11498
match office_lineage(&state, &session_id, &saved.execution_id, &q.path) { - 11499
Ok(lineage) => (Some(lineage), None), - 11500
Err(reason) => (None, Some(reason)), - 11501
} - 11502
}; - 11503
let before = current.is_file().then_some(current.as_path()); - 11504
match vak_tools::broker::office_review( - 11505
&state.core.tool_worker_exe(), - 11506
before, - 11507
&draft, - 11508
lineage.as_ref(), - 11509
) - 11510
.await - 11511
{ - 11512
Ok(mut body) => { - 11513
body["path"] = serde_json::Value::String(q.path.clone()); - 11514
body["compared_with"] = serde_json::Value::String( - 11515
if before.is_some() { - 11516
"workspace" - 11517
} else { - 11518
"nothing (new file)" - 11519
} - 11520
.into(), - 11521
); - 11522
if let Some(reason) = whole_reason { - 11523
body["choices_unavailable"] = serde_json::Value::String(reason); - 11524
} - 11525
if let Some(narrowed) = &saved.narrowed { - 11526
body["narrowed_from"] = serde_json::json!({ - 11527
"candidate_id": saved.parent_candidate_id, - 11528
"keep": narrowed.keep, - 11529
}); - 11530
} - 11531
Json(body).into_response() - 11532
} - 11533
Err(error) => ( - 11534
StatusCode::UNPROCESSABLE_ENTITY, - 11535
Json(serde_json::json!({ "error": error })), - 11536
) - 11537
.into_response(), - 11538
} - 11539
} - 11540
- 11541
/// The Canvas views of an Office file in a saved candidate: the frozen - 11542
/// draft, checked against its recorded hash first, parsed in the worker. - 11543
async fn read_sandbox_candidate_office_projection( - 11544
State(state): State<AppState>, - 11545
Path((session_id, candidate_id)): Path<(String, String)>, - 11546
axum::extract::Query(q): axum::extract::Query<OfficeProjectionQuery>, - 11547
) -> axum::response::Response { - 11548
use axum::response::IntoResponse; - 11549
if !vak_ooxml::is_openxml_path(&q.path) { - 11550
return ( - 11551
StatusCode::BAD_REQUEST, - 11552
"not a Word, Excel, PowerPoint or Visio file", - 11553
) - 11554
.into_response(); - 11555
} - 11556
if let Err(status) = - 11557
sandbox_candidate_file_bytes(&state, &session_id, &candidate_id, &q.path).await - 11558
{ - 11559
return status.into_response(); - 11560
} - 11561
let saved = match saved_candidate(&state, &session_id, &candidate_id) { - 11562
Ok(saved) => saved, - 11563
Err(status) => return status.into_response(), - 11564
}; - 11565
let Some(draft) = confined_path(&saved.candidate.source_root, &q.path) else { - 11566
return StatusCode::FORBIDDEN.into_response(); - 11567
}; - 11568
let view = q.view(); - 11569
match vak_tools::broker::office_project(&state.core.tool_worker_exe(), &draft, view).await { - 11570
Ok(mut body) => { - 11571
body["path"] = serde_json::Value::String(q.path.clone()); - 11572
Json(body).into_response() - 11573
} - 11574
Err(error) => ( - 11575
StatusCode::UNPROCESSABLE_ENTITY, - 11576
Json(serde_json::json!({ "error": error })), - 11577
) - 11578
.into_response(), - 11579
} - 11580
} - 11581
- 11582
#[derive(Debug, serde::Deserialize)] - 11583
struct OfficeNarrowBody { - 11584
path: String, - 11585
keep: Vec<String>, - 11586
} - 11587
- 11588
/// A new version of a candidate that keeps only the chosen changes of one - 11589
/// Office draft, replayed from its lineage in a worker that can write only - 11590
/// the new version's staging directory (docs/design/72, P3). The full draft - 11591
/// stays as it was; the new version is reviewed and accepted like any other. - 11592
async fn narrow_sandbox_candidate_office( - 11593
State(state): State<AppState>, - 11594
Path((session_id, candidate_id)): Path<(String, String)>, - 11595
Json(body): Json<OfficeNarrowBody>, - 11596
) -> axum::response::Response { - 11597
use axum::response::IntoResponse; - 11598
let refuse = |status: StatusCode, message: String| { - 11599
(status, Json(serde_json::json!({ "error": message }))).into_response() - 11600
}; - 11601
if !vak_ooxml::is_openxml_path(&body.path) { - 11602
return refuse( - 11603
StatusCode::BAD_REQUEST, - 11604
"not a Word, Excel, PowerPoint or Visio file".into(), - 11605
); - 11606
} - 11607
if body.keep.is_empty() { - 11608
return refuse( - 11609
StatusCode::BAD_REQUEST, - 11610
"no change was kept; reject the draft instead".into(), - 11611
); - 11612
} - 11613
if let Err(status) = - 11614
sandbox_candidate_file_bytes(&state, &session_id, &candidate_id, &body.path).await - 11615
{ - 11616
return status.into_response(); - 11617
} - 11618
let saved = match saved_candidate(&state, &session_id, &candidate_id) { - 11619
Ok(saved) => saved, - 11620
Err(status) => return status.into_response(), - 11621
}; - 11622
if saved.narrowed.is_some() || saved.revision_session_id.is_some() { - 11623
return refuse( - 11624
StatusCode::CONFLICT, - 11625
"choose changes from the full draft, not from a version made from it".into(), - 11626
); - 11627
} - 11628
let applied = vak_sandbox::load_records(&sandbox_records_path(&state)) - 11629
.map(|records| { - 11630
records.iter().any(|record| { - 11631
matches!(record, vak_sandbox::DurableRecord::Promotion(promoted) - 11632
if promoted.candidate_id == candidate_id) - 11633
}) - 11634
}) - 11635
.unwrap_or(true); - 11636
if applied { - 11637
return refuse(StatusCode::CONFLICT, "the draft was already applied".into()); - 11638
} - 11639
let lineage = match office_lineage(&state, &session_id, &saved.execution_id, &body.path) { - 11640
Ok(lineage) => lineage, - 11641
Err(reason) => return refuse(StatusCode::CONFLICT, reason), - 11642
}; - 11643
let id = uuid::Uuid::now_v7().to_string(); - 11644
let staging = state - 11645
.core - 11646
.sessions_home() - 11647
.join("sandbox") - 11648
.join("staging") - 11649
.join(&id); - 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,
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.