- 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)] - 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()
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.