- 10799
Ok(text) => text, - 10800
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - 10801
Err(error) => { - 10802
return ( - 10803
StatusCode::INTERNAL_SERVER_ERROR, - 10804
Json(serde_json::json!({"error": error.to_string()})), - 10805
) - 10806
.into_response(); - 10807
} - 10808
}; - 10809
let events = text - 10810
.lines() - 10811
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok()) - 10812
.collect::<Vec<_>>(); - 10813
Json(serde_json::json!({ "session_id": id, "events": events })).into_response() - 10814
} - 10815
- 10816
fn append_session_sandbox_event(home: &std::path::Path, session_id: &str, event: &AgentEvent) { - 10817
let AgentEvent::Sandbox(sandbox) = event else { - 10818
return; - 10819
}; - 10820
let path = home - 10821
.join("sandbox") - 10822
.join("executions") - 10823
.join(format!("{session_id}.jsonl")); - 10824
let Some(parent) = path.parent() else { - 10825
return; - 10826
}; - 10827
if std::fs::create_dir_all(parent).is_err() { - 10828
return; - 10829
} - 10830
let Ok(mut file) = std::fs::OpenOptions::new() - 10831
.create(true) - 10832
.append(true) - 10833
.open(path) - 10834
else { - 10835
return; - 10836
}; - 10837
let Ok(line) = serde_json::to_string(sandbox) else { - 10838
return; - 10839
}; - 10840
use std::io::Write; - 10841
let _ = writeln!(file, "{line}"); - 10842
} - 10843
- 10844
async fn list_sandbox_records(State(state): State<AppState>) -> axum::response::Response { - 10845
use axum::response::IntoResponse; - 10846
let path = sandbox_records_path(&state); - 10847
match vak_sandbox::load_records(&path) { - 10848
Ok(records) => Json(serde_json::json!({ "records": records })).into_response(), - 10849
Err(error) => ( - 10850
StatusCode::INTERNAL_SERVER_ERROR, - 10851
Json(serde_json::json!({ "error": error.to_string() })), - 10852
) - 10853
.into_response(), - 10854
} - 10855
} - 10856
- 10857
async fn list_session_sandbox_records( - 10858
State(state): State<AppState>, - 10859
Path(id): Path<String>, - 10860
) -> axum::response::Response { - 10861
use axum::response::IntoResponse; - 10862
let path = sandbox_records_path(&state); - 10863
match vak_sandbox::load_records(&path) { - 10864
Ok(records) => { - 10865
let records = records - 10866
.into_iter() - 10867
.filter(|record| match record { - 10868
vak_sandbox::DurableRecord::Candidate(value) => value.session_id == id, - 10869
vak_sandbox::DurableRecord::Promotion(value) => value.session_id == id, - 10870
vak_sandbox::DurableRecord::PromotionUndo(value) => value.session_id == id, - 10871
vak_sandbox::DurableRecord::WorkspaceCheck(value) => value.session_id == id, - 10872
vak_sandbox::DurableRecord::Environment(_) => false, - 10873
vak_sandbox::DurableRecord::PreviewPreparation(value) => value.session_id == id, - 10874
vak_sandbox::DurableRecord::CandidateRevision(value) => value.session_id == id, - 10875
}) - 10876
.collect::<Vec<_>>(); - 10877
Json(serde_json::json!({ "records": records })).into_response() - 10878
} - 10879
Err(error) => ( - 10880
StatusCode::INTERNAL_SERVER_ERROR, - 10881
Json(serde_json::json!({ "error": error.to_string() })), - 10882
) - 10883
.into_response(), - 10884
} - 10885
} - 10886
- 10887
fn sandbox_result_binding( - 10888
state: &AppState, - 10889
session_id: &str, - 10890
execution_id: &str, - 10891
) -> Result<(String, String), (StatusCode, &'static str)> { - 10892
let timeline = if let Some(handle) = state.get(session_id) { - 10893
let guard = handle - 10894
.session - 10895
.lock() - 10896
.unwrap_or_else(std::sync::PoisonError::into_inner); - 10897
if let Some(session) = guard.as_ref() { - 10898
crate::projection::snapshot(session_id, session) - 10899
} else { - 10900
handle - 10901
.presentation - 10902
.lock() - 10903
.unwrap_or_else(std::sync::PoisonError::into_inner) - 10904
.clone() - 10905
} - 10906
} else if let Some(session) = open_historical_session(state, session_id) { - 10907
crate::projection::snapshot(session_id, &session) - 10908
} else { - 10909
return Err((StatusCode::NOT_FOUND, "unknown session")); - 10910
}; - 10911
let Some(turn_id) = timeline.items.iter().find_map(|item| { - 10912
item.provenance.as_ref().and_then(|value| { - 10913
(value.tool_call_id.as_deref() == Some(execution_id)).then(|| item.turn_id.clone()) - 10914
}) - 10915
}) else { - 10916
return Err(( - 10917
StatusCode::NOT_FOUND, - 10918
"execution is not part of this session", - 10919
)); - 10920
}; - 10921
let Some(result_id) = timeline.items.iter().rev().find_map(|item| { - 10922
(item.turn_id == turn_id && item.role == vak_delivery::OutputRole::Assistant) - 10923
.then(|| item.outcome.as_ref().map(|value| value.result_id.clone())) - 10924
.flatten() - 10925
}) else { - 10926
return Err((StatusCode::CONFLICT, "result is not ready for review")); - 10927
}; - 10928
Ok((turn_id, result_id)) - 10929
} - 10930
- 10931
fn sandbox_execution_scratch( - 10932
state: &AppState, - 10933
session_id: &str, - 10934
execution_id: &str, - 10935
) -> Option<std::path::PathBuf> { - 10936
let workspace = sandbox_session_workspace(state, session_id)?; - 10937
let text = std::fs::read_to_string(session_sandbox_events_path(state, session_id)).ok()?; - 10938
text.lines().find_map(|line| { - 10939
let event = serde_json::from_str::<vak_tools::SandboxEvent>(line).ok()?; - 10940
match event { - 10941
vak_tools::SandboxEvent::ExecutionStarted { - 10942
execution_id: observed, - 10943
scratch_dir, - 10944
.. - 10945
} if observed == execution_id => confined_path(&workspace, &scratch_dir), - 10946
_ => None, - 10947
} - 10948
}) - 10949
} - 10950
- 10951
#[derive(Debug, serde::Deserialize)] - 10952
struct SandboxCandidateBody { - 10953
execution_id: String, - 10954
source: String, - 10955
#[serde(default)] - 10956
destination: String, - 10957
} - 10958
- 10959
fn javascript_package_manager( - 10960
package: &serde_json::Value, - 10961
has_effective_file: impl Fn(&str) -> bool, - 10962
) -> &'static str { - 10963
let declared = package - 10964
.get("packageManager") - 10965
.and_then(serde_json::Value::as_str) - 10966
.and_then(|value| value.split_once('@').map(|(manager, _)| manager)); - 10967
match declared { - 10968
Some("pnpm") => "pnpm", - 10969
Some("yarn") => "yarn", - 10970
Some("bun") => "bun", - 10971
Some("npm") => "npm", - 10972
_ if has_effective_file("pnpm-lock.yaml") => "pnpm", - 10973
_ if has_effective_file("yarn.lock") => "yarn", - 10974
_ if has_effective_file("bun.lock") || has_effective_file("bun.lockb") => "bun", - 10975
_ => "npm", - 10976
} - 10977
} - 10978
- 10979
fn planned_workspace_checks( - 10980
candidate: &vak_sandbox::CandidateManifest, - 10981
) -> Vec<vak_sandbox::WorkspaceCheckPlan> { - 10982
let effective_file = |name: &str| match candidate.files.iter().find(|file| file.path == name) { - 10983
Some(file) if file.operation == vak_sandbox::CandidateOperation::Upsert => { - 10984
Some(candidate.source_root.join(name)) - 10985
} - 10986
Some(_) => None, - 10987
None => Some(candidate.destination_root.join(name)), - 10988
}; - 10989
let has = |name: &str| effective_file(name).is_some_and(|path| path.is_file()); - 10990
let mut checks = Vec::new(); - 10991
if has("Cargo.toml") { - 10992
checks.push(vak_sandbox::WorkspaceCheckPlan { - 10993
id: "rust.cargo-test".into(), - 10994
label: "Rust tests".into(), - 10995
command: if has("Cargo.lock") { - 10996
"cargo test --locked".into() - 10997
} else { - 10998
"cargo test".into() - 10999
}, - 11000
}); - 11001
} - 11002
if let Some(package_path) = effective_file("package.json") - 11003
&& let Ok(bytes) = std::fs::read(package_path) - 11004
&& let Ok(package) = serde_json::from_slice::<serde_json::Value>(&bytes) - 11005
{ - 11006
let package_manager = javascript_package_manager(&package, has); - 11007
let script_command = |name: &str| format!("{package_manager} run {name}"); - 11008
let has_script = |name: &str| { - 11009
package - 11010
.get("scripts") - 11011
.and_then(|scripts| scripts.get(name)) - 11012
.and_then(serde_json::Value::as_str) - 11013
.is_some_and(|script| !script.trim().is_empty()) - 11014
}; - 11015
if has_script("build") { - 11016
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11017
id: "javascript.build".into(), - 11018
label: "Production build".into(), - 11019
command: script_command("build"), - 11020
}); - 11021
} - 11022
if has_script("test") { - 11023
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11024
id: "javascript.test".into(), - 11025
label: "Project tests".into(), - 11026
command: script_command("test"), - 11027
}); - 11028
} - 11029
} - 11030
if has("go.mod") { - 11031
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11032
id: "go.test".into(), - 11033
label: "Go tests".into(), - 11034
command: "go test ./...".into(), - 11035
}); - 11036
} - 11037
if has("pytest.ini") || has("conftest.py") { - 11038
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11039
id: "python.pytest".into(), - 11040
label: "Python tests".into(), - 11041
command: "python -m pytest".into(), - 11042
}); - 11043
} - 11044
checks - 11045
} - 11046
- 11047
/// The sandbox a dev-server preview runs in: the agent's own, plus - 11048
/// listening on a port where the backend can grant it (outbound connections - 11049
/// stay denied). A preview is a server the user configured; one that cannot - 11050
/// listen dies on start. - 11051
fn preview_sandbox(core: &vak_core::Core) -> Option<Arc<dyn vak_tools::sandbox::Sandbox>> { - 11052
let sandbox = core.agent_sandbox(); - 11053
sandbox - 11054
.as_ref() - 11055
.and_then(|sandbox| sandbox.listening_variant()) - 11056
.or(sandbox) - 11057
} - 11058
- 11059
async fn export_sandbox_candidate( - 11060
State(state): State<AppState>, - 11061
Path(session_id): Path<String>, - 11062
Json(body): Json<SandboxCandidateBody>, - 11063
) -> axum::response::Response { - 11064
use axum::response::IntoResponse; - 11065
let (turn_id, result_id) = match sandbox_result_binding(&state, &session_id, &body.execution_id) - 11066
{ - 11067
Ok(binding) => binding, - 11068
Err(error) => return error.into_response(), - 11069
}; - 11070
let Some(workspace) = sandbox_session_workspace(&state, &session_id) else { - 11071
return (StatusCode::NOT_FOUND, "unknown session").into_response(); - 11072
}; - 11073
let Some(source) = confined_path(&workspace, &body.source) else { - 11074
return (StatusCode::FORBIDDEN, "candidate source outside workspace").into_response(); - 11075
}; - 11076
let Some(execution_scratch) = - 11077
sandbox_execution_scratch(&state, &session_id, &body.execution_id) - 11078
else { - 11079
return ( - 11080
StatusCode::NOT_FOUND, - 11081
"sandbox execution evidence not found", - 11082
) - 11083
.into_response(); - 11084
}; - 11085
if std::fs::canonicalize(&source).ok() != std::fs::canonicalize(&execution_scratch).ok() { - 11086
return ( - 11087
StatusCode::FORBIDDEN, - 11088
"candidate source does not belong to this execution", - 11089
) - 11090
.into_response(); - 11091
} - 11092
// A command works in the workspace itself, so its files are already where - 11093
// they belong; only an execution that ran inside `.vak/scratch/` has a - 11094
// draft to promote. Freezing the workspace as its own candidate would - 11095
// promote it onto itself. - 11096
let scratch_root = std::fs::canonicalize(workspace.join(".vak").join("scratch")).ok(); - 11097
let in_scratch = std::fs::canonicalize(&execution_scratch) - 11098
.ok() - 11099
.zip(scratch_root) - 11100
.is_some_and(|(dir, root)| dir.starts_with(root)); - 11101
if !in_scratch { - 11102
return ( - 11103
StatusCode::CONFLICT, - 11104
"this execution worked in the workspace; its files are already there", - 11105
) - 11106
.into_response(); - 11107
} - 11108
let destination_text = if body.destination.trim().is_empty() { - 11109
".".to_string() - 11110
} else { - 11111
body.destination - 11112
}; - 11113
let Some(destination) = confined_path(&workspace, &destination_text) else { - 11114
return ( - 11115
StatusCode::FORBIDDEN, - 11116
"candidate destination outside workspace", - 11117
) - 11118
.into_response(); - 11119
}; - 11120
let id = uuid::Uuid::now_v7().to_string(); - 11121
let frozen_root = sandbox_candidates_root(&state).join(&id); - 11122
if let Err(error) = std::fs::create_dir_all(sandbox_candidates_root(&state)) { - 11123
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(); - 11124
} - 11125
match vak_sandbox::freeze_candidate(&id, &source, &destination, &frozen_root) { - 11126
Ok(mut candidate) => { - 11127
candidate.target_checks = vak_sandbox::default_target_verifiers().plan(&candidate); - 11128
candidate.workspace_checks = planned_workspace_checks(&candidate); - 11129
let draft_checks = vak_tools::broker::verify_targets( - 11130
&state.core.tool_worker_exe(), - 11131
&candidate.source_root, - 11132
&candidate.target_checks, - 11133
) - 11134
.await; - 11135
let candidate_digest = match vak_sandbox::candidate_digest(&candidate) { - 11136
Ok(value) => value, - 11137
Err(error) => { - 11138
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 11139
return ( - 11140
StatusCode::INTERNAL_SERVER_ERROR, - 11141
Json(serde_json::json!({"error": error.to_string()})), - 11142
) - 11143
.into_response(); - 11144
} - 11145
}; - 11146
let record = vak_sandbox::DurableRecord::Candidate(vak_sandbox::CandidateRecord { - 11147
record_id: format!("candidate-{id}"), - 11148
session_id, - 11149
turn_id, - 11150
result_id, - 11151
execution_id: body.execution_id, - 11152
environment_id: source.to_string_lossy().into_owned(), - 11153
candidate_digest, - 11154
candidate: candidate.clone(), - 11155
verified: true, - 11156
draft_checks, - 11157
updated_at: chrono::Utc::now().to_rfc3339(), - 11158
parent_candidate_id: None, - 11159
revision_session_id: None, - 11160
narrowed: None, - 11161
}); - 11162
match vak_sandbox::append_record(&sandbox_records_path(&state), &record) { - 11163
Ok(()) => Json(record).into_response(), - 11164
Err(error) => { - 11165
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 11166
( - 11167
StatusCode::INTERNAL_SERVER_ERROR, - 11168
Json(serde_json::json!({ "error": error.to_string() })), - 11169
) - 11170
.into_response() - 11171
} - 11172
} - 11173
} - 11174
Err(error) => ( - 11175
StatusCode::BAD_REQUEST, - 11176
Json(serde_json::json!({ "error": error.to_string() })), - 11177
) - 11178
.into_response(), - 11179
} - 11180
} - 11181
- 11182
async fn sandbox_candidate_file_bytes( - 11183
state: &AppState, - 11184
session_id: &str, - 11185
candidate_id: &str, - 11186
relative_path: &str, - 11187
) -> Result<Vec<u8>, StatusCode> { - 11188
let records = match vak_sandbox::load_records(&sandbox_records_path(state)) { - 11189
Ok(records) => records, - 11190
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), - 11191
}; - 11192
let Some(candidate) = records.iter().rev().find_map(|record| match record { - 11193
vak_sandbox::DurableRecord::Candidate(saved) - 11194
if saved.session_id == session_id && saved.candidate.candidate_id == candidate_id => - 11195
{ - 11196
Some(&saved.candidate) - 11197
} - 11198
_ => None, - 11199
}) else { - 11200
return Err(StatusCode::NOT_FOUND); - 11201
}; - 11202
let Some(file) = candidate - 11203
.files - 11204
.iter() - 11205
.find(|file| file.path == relative_path) - 11206
else { - 11207
return Err(StatusCode::NOT_FOUND); - 11208
}; - 11209
let expected_root = sandbox_candidates_root(state).join(candidate_id); - 11210
if candidate.source_root != expected_root { - 11211
return Err(StatusCode::FORBIDDEN); - 11212
} - 11213
let Some(path) = confined_path(&expected_root, &file.path) else { - 11214
return Err(StatusCode::FORBIDDEN); - 11215
}; - 11216
let bytes = match tokio::fs::read(&path).await { - 11217
Ok(bytes) => bytes, - 11218
Err(_) => return Err(StatusCode::NOT_FOUND), - 11219
}; - 11220
if vak_sandbox::digest(&bytes) != file.candidate_hash { - 11221
return Err(StatusCode::CONFLICT); - 11222
} - 11223
Ok(bytes) - 11224
} - 11225
- 11226
fn saved_candidate( - 11227
state: &AppState, - 11228
session_id: &str, - 11229
candidate_id: &str, - 11230
) -> Result<vak_sandbox::CandidateRecord, StatusCode> { - 11231
let records = vak_sandbox::load_records(&sandbox_records_path(state)) - 11232
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - 11233
records - 11234
.into_iter() - 11235
.rev() - 11236
.find_map(|record| match record { - 11237
vak_sandbox::DurableRecord::Candidate(saved) - 11238
if saved.session_id == session_id - 11239
&& saved.candidate.candidate_id == candidate_id => - 11240
{ - 11241
Some(saved) - 11242
} - 11243
_ => None, - 11244
}) - 11245
.ok_or(StatusCode::NOT_FOUND) - 11246
} - 11247
- 11248
/// How the Office draft `path` of execution `execution_id` was made: the - 11249
/// `office_apply` calls in this session's ledger, followed back through - 11250
/// each call whose source was an earlier draft to the file the chain - 11251
/// started from (docs/design/72, P3). Only successful calls count; the - 11252
/// worker then refuses a lineage that does not reproduce the draft. - 11253
/// Each `office_apply` call in a session that succeeded, in ledger order, - 11254
/// with its arguments. A call's id is its execution id, so it names the - 11255
/// directory its draft is in (`vak_tools::office_apply::draft_dir`). - 11256
fn successful_office_calls(log: &vak_session::SessionLog) -> Vec<(String, serde_json::Value)> { - 11257
let mut calls = Vec::new(); - 11258
let mut succeeded = std::collections::HashSet::new(); - 11259
for (_, message) in log.message_chain() { - 11260
for block in &message.content { - 11261
match block { - 11262
vak_llm::ContentBlock::ToolUse { id, name, input } if name == "office_apply" => { - 11263
calls.push((id.clone(), input.clone())); - 11264
} - 11265
vak_llm::ContentBlock::ToolResult { - 11266
tool_use_id, - 11267
is_error: false, - 11268
.. - 11269
} => { - 11270
succeeded.insert(tool_use_id.clone()); - 11271
} - 11272
_ => {} - 11273
} - 11274
} - 11275
} - 11276
calls.retain(|(id, _)| succeeded.contains(id)); - 11277
calls - 11278
} - 11279
- 11280
/// The Office drafts a revision turn delivered in its task copy, one per - 11281
/// file: the newest successful `office_apply` call for that file whose draft - 11282
/// exists. Each draft is a whole file, so a later one supersedes an earlier - 11283
/// one; a call repeated verbatim is answered with the earlier draft and - 11284
/// writes none, which is why a missing draft is passed over. - 11285
fn revision_office_drafts( - 11286
log: &vak_session::SessionLog, - 11287
task_root: &std::path::Path, - 11288
) -> Vec<vak_sandbox::RevisionDraft> { - 11289
let Ok(root) = task_root.canonicalize() else { - 11290
return Vec::new(); - 11291
}; - 11292
let agent = log - 11293
.header() - 11294
.and_then(|header| header.agent.as_ref().map(|agent| agent.id.clone())); - 11295
let mut drafts: Vec<vak_sandbox::RevisionDraft> = Vec::new(); - 11296
for (call_id, args) in successful_office_calls(log).into_iter().rev() { - 11297
let Some(relative) = args - 11298
.get("path") - 11299
.and_then(serde_json::Value::as_str) - 11300
.and_then(|path| confined_path(&root, path.trim())) - 11301
.and_then(|path| { - 11302
path.strip_prefix(&root) - 11303
.ok() - 11304
.map(std::path::Path::to_path_buf) - 11305
}) - 11306
else { - 11307
continue; - 11308
}; - 11309
let path = relative.to_string_lossy().replace('\\', "/"); - 11310
if drafts.iter().any(|draft| draft.path == path) { - 11311
continue; - 11312
} - 11313
let draft = vak_tools::office_apply::draft_dir(agent.as_deref(), &call_id).join(&relative); - 11314
if root.join(&draft).is_file() { - 11315
drafts.push(vak_sandbox::RevisionDraft { - 11316
path, - 11317
draft: draft.to_string_lossy().replace('\\', "/"), - 11318
}); - 11319
} - 11320
} - 11321
drafts - 11322
} - 11323
- 11324
fn office_lineage( - 11325
state: &AppState, - 11326
session_id: &str, - 11327
execution_id: &str, - 11328
path: &str, - 11329
) -> Result<vak_tools::broker::OfficeLineage, String> { - 11330
let workspace = sandbox_session_workspace(state, session_id).ok_or("unknown session")?; - 11331
let root = workspace - 11332
.canonicalize() - 11333
.map_err(|error| format!("cannot resolve the workspace: {error}"))?; - 11334
// A file not yet in the workspace is a new document, whose Word edits - 11335
// were written clean; the replay must write them the same way. - 11336
let new_file = !confined_path(&root, path).is_some_and(|file| file.is_file()); - 11337
let collect = |log: &vak_session::SessionLog| { - 11338
let calls: std::collections::HashMap<_, _> = - 11339
successful_office_calls(log).into_iter().collect(); - 11340
let agent = log - 11341
.header() - 11342
.and_then(|header| header.agent.as_ref().map(|agent| agent.id.clone())); - 11343
(calls, agent) - 11344
}; - 11345
let live = state.get(session_id).and_then(|handle| { - 11346
handle - 11347
.session - 11348
.lock() - 11349
.unwrap_or_else(std::sync::PoisonError::into_inner) - 11350
.as_ref() - 11351
.map(collect) - 11352
}); - 11353
let (calls, agent) = match live { - 11354
Some(found) => found, - 11355
None => open_historical_session(state, session_id) - 11356
.map(|log| collect(&log)) - 11357
.ok_or("the session ledger cannot be read")?, - 11358
}; - 11359
let relative = |value: &str| -> Option<std::path::PathBuf> { - 11360
confined_path(&root, value)? - 11361
.strip_prefix(&root) - 11362
.ok() - 11363
.map(std::path::Path::to_path_buf) - 11364
}; - 11365
let mut ops = Vec::new(); - 11366
let mut call_id = execution_id.to_string(); - 11367
let mut wanted = std::path::PathBuf::from(path); - 11368
for _ in 0..64 { - 11369
let Some(args) = calls.get(&call_id) else { - 11370
return Err( - 11371
"the draft was not made by a successful office_apply call in this conversation" - 11372
.into(), - 11373
); - 11374
}; - 11375
let target = args - 11376
.get("path") - 11377
.and_then(serde_json::Value::as_str) - 11378
.and_then(relative); - 11379
if target.as_deref() != Some(wanted.as_path()) { - 11380
return Err(format!( - 11381
"office_apply {call_id} did not write {}", - 11382
wanted.display() - 11383
)); - 11384
} - 11385
let mut call_ops: Vec<vak_ooxml::edit::OfficeOp> = args - 11386
.get("ops") - 11387
.cloned() - 11388
.and_then(|ops| serde_json::from_value(ops).ok()) - 11389
.ok_or("a recorded office_apply call has ops this version cannot read")?; - 11390
call_ops.append(&mut ops); - 11391
ops = call_ops; - 11392
let source_text = args - 11393
.get("source") - 11394
.and_then(serde_json::Value::as_str) - 11395
.map(str::trim) - 11396
.filter(|source| !source.is_empty()) - 11397
.or_else(|| args.get("path").and_then(serde_json::Value::as_str)) - 11398
.unwrap_or_default(); - 11399
let source = relative(source_text).ok_or("a draft's source is outside the workspace")?; - 11400
let parts: Vec<String> = source - 11401
.components() - 11402
.map(|part| part.as_os_str().to_string_lossy().into_owned()) - 11403
.collect(); - 11404
match parts.as_slice() { - 11405
[vak, scratch, _agent, earlier, rest @ ..] if vak == ".vak" && scratch == "scratch" => { - 11406
call_id = earlier.clone(); - 11407
wanted = rest.iter().collect(); - 11408
} - 11409
[vak, ..] if vak == ".vak" => { - 11410
return Err("a draft's source is inside .vak but not an earlier draft".into()); - 11411
} - 11412
_ => { - 11413
let base_digest = args - 11414
.get("base_digest") - 11415
.and_then(serde_json::Value::as_str) - 11416
.map(str::trim) - 11417
.unwrap_or_default() - 11418
.to_string(); - 11419
let named_source = args - 11420
.get("source") - 11421
.and_then(serde_json::Value::as_str) - 11422
.is_some_and(|source| !source.trim().is_empty()); - 11423
// A successful call with neither a source nor a digest made - 11424
// its file from the built-in blank ("Creating from scratch"). - 11425
let origin = if !named_source && base_digest.is_empty() { - 11426
vak_tools::broker::OfficeOrigin::Blank - 11427
} else { - 11428
vak_tools::broker::OfficeOrigin::File { - 11429
path: root.join(source), - 11430
base_digest, - 11431
} - 11432
}; - 11433
let from_blank = origin == vak_tools::broker::OfficeOrigin::Blank; - 11434
return Ok(vak_tools::broker::OfficeLineage { - 11435
origin, - 11436
ops, - 11437
author: vak_tools::office_apply::tracked_change_author( - 11438
agent.as_deref().unwrap_or("vak"), - 11439
), - 11440
new_file: new_file || from_blank, - 11441
}); - 11442
} - 11443
} - 11444
} - 11445
Err("the chain of drafts is too long to follow".into()) - 11446
} - 11447
- 11448
/// What an Office file in a candidate changes, compared with the workspace - 11449
/// file it would replace, and the changes a person can keep or leave out - 11450
/// (docs/design/72, P3). Every package is parsed in a worker, never in the - 11451
/// server (invariant 39). - 11452
async fn read_sandbox_candidate_office_review( - 11453
State(state): State<AppState>, - 11454
Path((session_id, candidate_id)): Path<(String, String)>, - 11455
axum::extract::Query(q): axum::extract::Query<FileQuery>, - 11456
) -> axum::response::Response { - 11457
use axum::response::IntoResponse; - 11458
if !vak_ooxml::is_openxml_path(&q.path) { - 11459
return ( - 11460
StatusCode::BAD_REQUEST, - 11461
"not a Word, Excel, PowerPoint or Visio file", - 11462
) - 11463
.into_response(); - 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)]
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.