- 1
//! Shared, versioned editing rooms for one Office file in a saved candidate. - 2
//! - 3
//! Room metadata is a replaceable snapshot for fast reads; its revisions are - 4
//! append-only entries, each pointing at an immutable saved candidate. Office - 5
//! bytes are always produced and checked by the document worker. - 6
- 7
use axum::{ - 8
Json, - 9
extract::{Path, State}, - 10
http::StatusCode, - 11
response::IntoResponse, - 12
}; - 13
use serde::{Deserialize, Serialize}; - 14
use std::{ - 15
fs, - 16
path::{Path as FsPath, PathBuf}, - 17
}; - 18
- 19
use crate::{AppState, AuthenticatedPrincipal}; - 20
- 21
const MAX_OPS: usize = 32; - 22
const MAX_OPERATION_BYTES: usize = 32 * 1024; - 23
- 24
#[derive(Debug, Clone, Serialize, Deserialize)] - 25
struct OfficeRoom { - 26
schema: u32, - 27
room_id: String, - 28
session_id: String, - 29
path: String, - 30
created_at: String, - 31
branches: Vec<OfficeBranch>, - 32
revisions: Vec<OfficeRevision>, - 33
} - 34
- 35
#[derive(Debug, Clone, Serialize, Deserialize)] - 36
struct OfficeBranch { - 37
branch_id: String, - 38
name: String, - 39
base_candidate_id: String, - 40
head_candidate_id: String, - 41
shared: bool, - 42
archived: bool, - 43
} - 44
- 45
#[derive(Debug, Clone, Serialize, Deserialize)] - 46
struct OfficeRevision { - 47
candidate_id: String, - 48
parent_candidate_id: String, - 49
#[serde(default)] - 50
merge_parent_candidate_id: Option<String>, - 51
branch_id: String, - 52
author_id: String, - 53
author_name: String, - 54
created_at: String, - 55
ops: Vec<vak_ooxml::edit::OfficeOp>, - 56
} - 57
- 58
#[derive(Debug, Deserialize)] - 59
pub(super) struct CreateBody { - 60
candidate_id: String, - 61
path: String, - 62
} - 63
- 64
#[derive(Debug, Deserialize)] - 65
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] - 66
pub(super) enum Action { - 67
Branch { - 68
name: String, - 69
expected_head: String, - 70
}, - 71
Edit { - 72
branch_id: String, - 73
expected_head: String, - 74
ops: Vec<vak_ooxml::edit::OfficeOp>, - 75
}, - 76
Merge { - 77
branch_id: String, - 78
expected_shared_head: String, - 79
}, - 80
Import { - 81
branch_id: String, - 82
expected_head: String, - 83
candidate_id: String, - 84
}, - 85
} - 86
- 87
#[derive(Debug, Deserialize)] - 88
pub(super) struct FocusBody { - 89
anchor: Option<String>, - 90
#[serde(default = "default_focus_active")] - 91
active: bool, - 92
} - 93
- 94
fn default_focus_active() -> bool { - 95
true - 96
} - 97
- 98
fn id_ok(value: &str) -> bool { - 99
!value.is_empty() - 100
&& value.len() <= 128 - 101
&& value - 102
.bytes() - 103
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') - 104
} - 105
- 106
fn room_path(state: &AppState, session_id: &str, room_id: &str) -> Option<PathBuf> { - 107
if !id_ok(session_id) || !id_ok(room_id) { - 108
return None; - 109
} - 110
Some( - 111
vak_config::paths::office_workspaces_at(&state.core.sessions_home(), session_id) - 112
.join(format!("{room_id}.json")), - 113
) - 114
} - 115
- 116
fn load(path: &FsPath) -> Result<OfficeRoom, ()> { - 117
let bytes = fs::read(path).map_err(|_| ())?; - 118
if bytes.len() > 4 * 1024 * 1024 { - 119
return Err(()); - 120
} - 121
serde_json::from_slice(&bytes).map_err(|_| ()) - 122
} - 123
- 124
fn save(path: &FsPath, room: &OfficeRoom) -> Result<(), ()> { - 125
let parent = path.parent().ok_or(())?; - 126
fs::create_dir_all(parent).map_err(|_| ())?; - 127
let temp = parent.join(format!(".{}.{}.tmp", room.room_id, uuid::Uuid::now_v7())); - 128
let bytes = serde_json::to_vec(room).map_err(|_| ())?; - 129
if bytes.len() > 4 * 1024 * 1024 { - 130
return Err(()); - 131
} - 132
let result = (|| { - 133
use std::io::Write; - 134
let mut file = fs::OpenOptions::new() - 135
.write(true) - 136
.create_new(true) - 137
.open(&temp)?; - 138
file.write_all(&bytes)?; - 139
file.sync_all()?; - 140
fs::rename(&temp, path)?; - 141
Ok::<(), std::io::Error>(()) - 142
})(); - 143
if result.is_err() { - 144
let _ = fs::remove_file(temp); - 145
} - 146
result.map_err(|_| ()) - 147
} - 148
- 149
fn authority( - 150
state: &AppState, - 151
session_id: &str, - 152
principal: &AuthenticatedPrincipal, - 153
edit: bool, - 154
) -> Result<(String, String, String), StatusCode> { - 155
match principal { - 156
AuthenticatedPrincipal::Operator => Ok(("operator".into(), "You".into(), "owner".into())), - 157
AuthenticatedPrincipal::Participant(p) => { - 158
let audience = - 159
super::conversation_audience(state, session_id).ok_or(StatusCode::NOT_FOUND)?; - 160
if p.conversation_id != session_id - 161
|| p.audience_id != audience - 162
|| !p.capabilities.iter().any(|c| c == "read") - 163
{ - 164
return Err(StatusCode::FORBIDDEN); - 165
} - 166
if edit && !p.capabilities.iter().any(|c| c == "edit") { - 167
return Err(StatusCode::FORBIDDEN); - 168
} - 169
Ok(( - 170
p.principal_id.clone(), - 171
p.display_name.clone(), - 172
p.grant_id.clone(), - 173
)) - 174
} - 175
} - 176
} - 177
- 178
pub(super) async fn create( - 179
State(state): State<AppState>, - 180
Path(session_id): Path<String>, - 181
axum::Extension(principal): axum::Extension<AuthenticatedPrincipal>, - 182
Json(body): Json<CreateBody>, - 183
) -> axum::response::Response { - 184
if !matches!(principal, AuthenticatedPrincipal::Operator) { - 185
return StatusCode::FORBIDDEN.into_response(); - 186
} - 187
if !vak_ooxml::is_openxml_path(&body.path) { - 188
return ( - 189
StatusCode::BAD_REQUEST, - 190
"Choose a Word, Excel, PowerPoint or Visio file.", - 191
) - 192
.into_response(); - 193
} - 194
let saved = match super::saved_candidate(&state, &session_id, &body.candidate_id) { - 195
Ok(v) => v, - 196
Err(s) => return s.into_response(), - 197
}; - 198
if !saved - 199
.candidate - 200
.files - 201
.iter() - 202
.any(|f| f.path == body.path && f.operation == vak_sandbox::CandidateOperation::Upsert) - 203
{ - 204
return StatusCode::NOT_FOUND.into_response(); - 205
} - 206
if let Err(status) = - 207
super::sandbox_candidate_file_bytes(&state, &session_id, &body.candidate_id, &body.path) - 208
.await - 209
{ - 210
return status.into_response(); - 211
} - 212
let room_id = uuid::Uuid::now_v7().to_string(); - 213
let now = chrono::Utc::now().to_rfc3339(); - 214
let room = OfficeRoom { - 215
schema: 1, - 216
room_id: room_id.clone(), - 217
session_id: session_id.clone(), - 218
path: body.path, - 219
created_at: now.clone(), - 220
branches: vec![OfficeBranch { - 221
branch_id: "shared".into(), - 222
name: "Shared draft".into(), - 223
base_candidate_id: body.candidate_id.clone(), - 224
head_candidate_id: body.candidate_id.clone(), - 225
shared: true, - 226
archived: false, - 227
}], - 228
revisions: vec![OfficeRevision { - 229
candidate_id: body.candidate_id, - 230
parent_candidate_id: String::new(), - 231
merge_parent_candidate_id: None, - 232
branch_id: "shared".into(), - 233
author_id: "operator".into(), - 234
author_name: "You".into(), - 235
created_at: now, - 236
ops: Vec::new(), - 237
}], - 238
}; - 239
let Some(path) = room_path(&state, &session_id, &room_id) else { - 240
return StatusCode::BAD_REQUEST.into_response(); - 241
}; - 242
if save(&path, &room).is_err() { - 243
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 244
} - 245
Json(room).into_response() - 246
} - 247
- 248
pub(super) async fn list( - 249
State(state): State<AppState>, - 250
Path(session_id): Path<String>, - 251
axum::Extension(principal): axum::Extension<AuthenticatedPrincipal>, - 252
) -> axum::response::Response { - 253
if !id_ok(&session_id) { - 254
return StatusCode::BAD_REQUEST.into_response(); - 255
} - 256
if let Err(status) = authority(&state, &session_id, &principal, false) { - 257
return status.into_response(); - 258
} - 259
let root = vak_config::paths::office_workspaces_at(&state.core.sessions_home(), &session_id); - 260
let entries = match fs::read_dir(root) { - 261
Ok(e) => e, - 262
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - 263
return Json(serde_json::json!({"workspaces": []})).into_response(); - 264
} - 265
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 266
}; - 267
let mut rooms = Vec::new(); - 268
for entry in entries.flatten() { - 269
if entry.path().extension().is_some_and(|e| e == "json") { - 270
match load(&entry.path()) { - 271
Ok(room) if room.session_id == session_id => rooms.push(room), - 272
Ok(_) => return StatusCode::FORBIDDEN.into_response(), - 273
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 274
} - 275
} - 276
} - 277
rooms.sort_by(|a, b| a.created_at.cmp(&b.created_at)); - 278
Json(serde_json::json!({"workspaces": rooms})).into_response() - 279
} - 280
- 281
pub(super) async fn focus( - 282
State(state): State<AppState>, - 283
Path((session_id, room_id)): Path<(String, String)>, - 284
axum::Extension(principal): axum::Extension<AuthenticatedPrincipal>, - 285
Json(body): Json<FocusBody>, - 286
) -> axum::response::Response { - 287
let (principal_id, display_name, _) = match authority(&state, &session_id, &principal, false) { - 288
Ok(v) => v, - 289
Err(s) => return s.into_response(), - 290
}; - 291
let Some(path) = room_path(&state, &session_id, &room_id) else { - 292
return StatusCode::BAD_REQUEST.into_response(); - 293
}; - 294
let room = match load(&path) { - 295
Ok(room) if room.session_id == session_id => room, - 296
Ok(_) => return StatusCode::FORBIDDEN.into_response(), - 297
Err(_) => return StatusCode::NOT_FOUND.into_response(), - 298
}; - 299
if body - 300
.anchor - 301
.as_deref() - 302
.is_some_and(|anchor| anchor.len() > 256 || !vak_ooxml::is_anchor(anchor)) - 303
{ - 304
return StatusCode::BAD_REQUEST.into_response(); - 305
} - 306
super::touch_coworking_presence(&state, &session_id, &principal_id, &display_name); - 307
if let Ok(mut all) = state.coworking_presence.lock() - 308
&& let Some(presence) = all - 309
.get_mut(&session_id) - 310
.and_then(|people| people.get_mut(&principal_id)) - 311
{ - 312
presence.office_room_id = body.active.then(|| room_id.clone()); - 313
presence.office_anchor = body.active.then_some(body.anchor).flatten(); - 314
presence.seen_at = std::time::Instant::now(); - 315
} - 316
if let Some(handle) = state.get(&session_id) { - 317
let _ = handle.coworking_comments_tx.send(()); - 318
} - 319
Json(serde_json::json!({"ok":true,"room_id":room.room_id})).into_response() - 320
} - 321
- 322
pub(super) async fn mutate( - 323
State(state): State<AppState>, - 324
Path((session_id, room_id)): Path<(String, String)>, - 325
axum::Extension(principal): axum::Extension<AuthenticatedPrincipal>, - 326
Json(action): Json<Action>, - 327
) -> axum::response::Response { - 328
let (actor_id, actor_name, _grant) = match authority(&state, &session_id, &principal, true) { - 329
Ok(v) => v, - 330
Err(s) => return s.into_response(), - 331
}; - 332
let Some(path) = room_path(&state, &session_id, &room_id) else { - 333
return StatusCode::BAD_REQUEST.into_response(); - 334
}; - 335
let lock_path = path.with_extension("lock"); - 336
if let Some(parent) = lock_path.parent() - 337
&& fs::create_dir_all(parent).is_err() - 338
{ - 339
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 340
} - 341
let lock = match fs::OpenOptions::new() - 342
.read(true) - 343
.write(true) - 344
.create(true) - 345
.truncate(false) - 346
.open(&lock_path) - 347
{ - 348
Ok(f) => f, - 349
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 350
}; - 351
if lock.try_lock().is_err() { - 352
return ( - 353
StatusCode::CONFLICT, - 354
Json(serde_json::json!({"error":"This draft is being updated; try again shortly."})), - 355
) - 356
.into_response(); - 357
} - 358
let mut room = match load(&path) { - 359
Ok(r) if r.session_id == session_id && r.room_id == room_id => r, - 360
Ok(_) => return StatusCode::FORBIDDEN.into_response(), - 361
Err(_) => return StatusCode::NOT_FOUND.into_response(), - 362
}; - 363
let (branch_id, current_head) = match &action { - 364
Action::Branch { expected_head, .. } => ("shared", expected_head), - 365
Action::Edit { - 366
branch_id, - 367
expected_head, - 368
.. - 369
} - 370
| Action::Import { - 371
branch_id, - 372
expected_head, - 373
.. - 374
} => (branch_id.as_str(), expected_head), - 375
Action::Merge { - 376
branch_id: _, - 377
expected_shared_head, - 378
} => ("shared", expected_shared_head), - 379
}; - 380
let Some(branch_pos) = room - 381
.branches - 382
.iter() - 383
.position(|b| b.branch_id == branch_id && !b.archived) - 384
else { - 385
return StatusCode::NOT_FOUND.into_response(); - 386
}; - 387
if room.branches[branch_pos].head_candidate_id != *current_head { - 388
return (StatusCode::CONFLICT, Json(serde_json::json!({"error":"This version changed. Reload the draft before editing."}))).into_response(); - 389
} - 390
match action { - 391
Action::Branch { name, .. } => { - 392
let name = name.trim(); - 393
if name.is_empty() - 394
|| name.chars().count() > 80 - 395
|| name.chars().any(char::is_control) - 396
|| room.branches.len() >= 24 - 397
{ - 398
return StatusCode::BAD_REQUEST.into_response(); - 399
} - 400
let branch_id = uuid::Uuid::now_v7().to_string(); - 401
let base = room.branches[branch_pos].head_candidate_id.clone(); - 402
room.branches.push(OfficeBranch { - 403
branch_id: branch_id.clone(), - 404
name: name.into(), - 405
base_candidate_id: base.clone(), - 406
head_candidate_id: base, - 407
shared: false, - 408
archived: false, - 409
}); - 410
if save(&path, &room).is_err() { - 411
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 412
} - 413
Json(serde_json::json!({"workspace":room})).into_response() - 414
} - 415
Action::Edit { branch_id, ops, .. } => { - 416
if ops.is_empty() - 417
|| ops.len() > MAX_OPS - 418
|| serde_json::to_vec(&ops).map_or(true, |b| b.len() > MAX_OPERATION_BYTES) - 419
{ - 420
return StatusCode::BAD_REQUEST.into_response(); - 421
} - 422
return create_revision( - 423
&state, &path, room, branch_pos, branch_id, actor_id, actor_name, ops, None, - 424
) - 425
.await; - 426
} - 427
Action::Import { - 428
branch_id, - 429
candidate_id, - 430
.. - 431
} => { - 432
let imported = match super::saved_candidate(&state, &session_id, &candidate_id) { - 433
Ok(c) => c, - 434
Err(s) => return s.into_response(), - 435
}; - 436
if !imported.candidate.files.iter().any(|f| { - 437
f.path == room.path && f.operation == vak_sandbox::CandidateOperation::Upsert - 438
}) { - 439
return StatusCode::BAD_REQUEST.into_response(); - 440
} - 441
let parent_id = room.branches[branch_pos].head_candidate_id.clone(); - 442
let imported_parent_id = match imported.parent_candidate_id.as_deref() { - 443
Some(id) => id.to_string(), - 444
None => { - 445
return ( - 446
StatusCode::CONFLICT, - 447
"This saved version has no recorded base to compare against.", - 448
) - 449
.into_response(); - 450
} - 451
}; - 452
let base_bytes = match super::sandbox_candidate_file_bytes( - 453
&state, - 454
&session_id, - 455
&imported_parent_id, - 456
&room.path, - 457
) - 458
.await - 459
{ - 460
Ok(b) => b, - 461
Err(s) => return s.into_response(), - 462
}; - 463
let head_bytes = match super::sandbox_candidate_file_bytes( - 464
&state, - 465
&session_id, - 466
&parent_id, - 467
&room.path, - 468
) - 469
.await - 470
{ - 471
Ok(b) => b, - 472
Err(s) => return s.into_response(), - 473
}; - 474
let base_hash = vak_sandbox::digest(&base_bytes); - 475
let head_hash = vak_sandbox::digest(&head_bytes); - 476
if base_hash != head_hash { - 477
return (StatusCode::CONFLICT, Json(serde_json::json!({"error":"The Agent version starts from another document version. Review it separately or start a branch from that version."}))).into_response(); - 478
} - 479
let lineage = match super::office_lineage( - 480
&state, - 481
&session_id, - 482
&imported.execution_id, - 483
&room.path, - 484
) { - 485
Ok(lineage) => lineage, - 486
Err(reason) => { - 487
return ( - 488
StatusCode::CONFLICT, - 489
Json(serde_json::json!({"error":reason})), - 490
) - 491
.into_response(); - 492
} - 493
}; - 494
if let Err(status) = - 495
super::sandbox_candidate_file_bytes(&state, &session_id, &candidate_id, &room.path) - 496
.await - 497
{ - 498
return status.into_response(); - 499
} - 500
let branch = &mut room.branches[branch_pos]; - 501
branch.head_candidate_id = candidate_id.clone(); - 502
room.revisions.push(OfficeRevision { - 503
candidate_id, - 504
parent_candidate_id: parent_id, - 505
merge_parent_candidate_id: None, - 506
branch_id, - 507
author_id: actor_id, - 508
author_name: actor_name, - 509
created_at: chrono::Utc::now().to_rfc3339(), - 510
ops: lineage.ops, - 511
}); - 512
if save(&path, &room).is_err() { - 513
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 514
} - 515
Json(serde_json::json!({"workspace":room})).into_response() - 516
} - 517
Action::Merge { branch_id, .. } => { - 518
let shared_head = room.branches[branch_pos].head_candidate_id.clone(); - 519
let Some(other_pos) = room - 520
.branches - 521
.iter() - 522
.position(|b| b.branch_id == branch_id && !b.shared && !b.archived) - 523
else { - 524
return StatusCode::NOT_FOUND.into_response(); - 525
}; - 526
let source_head = room.branches[other_pos].head_candidate_id.clone(); - 527
if source_head == shared_head { - 528
return Json(room).into_response(); - 529
} - 530
let base = room.branches[other_pos].base_candidate_id.clone(); - 531
let shared_ops = operations_since(&room, &shared_head, &base); - 532
let branch_ops = operations_since(&room, &source_head, &base); - 533
let (Some(shared_ops), Some(branch_ops)) = (shared_ops, branch_ops) else { - 534
return (StatusCode::CONFLICT, Json(serde_json::json!({"error":"These versions no longer share a mergeable base."}))).into_response(); - 535
}; - 536
if has_conflict(&shared_ops, &branch_ops) { - 537
return (StatusCode::CONFLICT, Json(serde_json::json!({"error":"Both versions changed the same document area. Review them separately; this merge needs a manual edit."}))).into_response(); - 538
} - 539
return create_revision( - 540
&state, - 541
&path, - 542
room, - 543
branch_pos, - 544
"shared".into(), - 545
actor_id, - 546
actor_name, - 547
branch_ops, - 548
Some(source_head), - 549
) - 550
.await; - 551
} - 552
} - 553
} - 554
- 555
#[allow(clippy::too_many_arguments)] - 556
async fn create_revision( - 557
state: &AppState, - 558
path: &FsPath, - 559
mut room: OfficeRoom, - 560
branch_pos: usize, - 561
branch_id: String, - 562
actor_id: String, - 563
actor_name: String, - 564
ops: Vec<vak_ooxml::edit::OfficeOp>, - 565
merge_parent: Option<String>, - 566
) -> axum::response::Response { - 567
let parent_id = room.branches[branch_pos].head_candidate_id.clone(); - 568
let session_id = room.session_id.clone(); - 569
let parent = match super::saved_candidate(state, &session_id, &parent_id) { - 570
Ok(c) => c, - 571
Err(s) => return s.into_response(), - 572
}; - 573
let Some(parent_file) = - 574
parent.candidate.files.iter().find(|f| { - 575
f.path == room.path && f.operation == vak_sandbox::CandidateOperation::Upsert - 576
}) - 577
else { - 578
return StatusCode::CONFLICT.into_response(); - 579
}; - 580
let id = uuid::Uuid::now_v7().to_string(); - 581
let staging_root = state - 582
.core - 583
.sessions_home() - 584
.join("sandbox") - 585
.join("staging") - 586
.join(&id); - 587
if vak_sandbox::prepare_revision_copy(&parent.candidate, &staging_root).is_err() { - 588
return StatusCode::CONFLICT.into_response(); - 589
} - 590
let Some(draft_path) = super::confined_path(&staging_root, &room.path) else { - 591
let _ = fs::remove_dir_all(&staging_root); - 592
return StatusCode::FORBIDDEN.into_response(); - 593
}; - 594
let Some(parent_source) = super::confined_path(&parent.candidate.source_root, &room.path) - 595
else { - 596
let _ = fs::remove_dir_all(&staging_root); - 597
return StatusCode::FORBIDDEN.into_response(); - 598
}; - 599
let Some(extension) = FsPath::new(&room.path).extension().and_then(|e| e.to_str()) else { - 600
let _ = fs::remove_dir_all(&staging_root); - 601
return StatusCode::BAD_REQUEST.into_response(); - 602
}; - 603
let output_path = draft_path.with_file_name(format!("vak-{id}.{extension}")); - 604
let lineage = vak_tools::broker::OfficeLineage { - 605
origin: vak_tools::broker::OfficeOrigin::File { - 606
path: parent_source, - 607
base_digest: parent_file.candidate_hash.clone(), - 608
}, - 609
ops: ops.clone(), - 610
author: actor_name.clone(), - 611
new_file: false, - 612
}; - 613
let applied = match vak_tools::broker::office_apply_to( - 614
&state.core.tool_worker_exe(), - 615
&lineage, - 616
&output_path, - 617
) - 618
.await - 619
{ - 620
Ok(a) => a, - 621
Err(e) => { - 622
let _ = fs::remove_dir_all(&staging_root); - 623
return ( - 624
StatusCode::UNPROCESSABLE_ENTITY, - 625
Json(serde_json::json!({"error":e})), - 626
) - 627
.into_response(); - 628
} - 629
}; - 630
if fs::remove_file(&draft_path).is_err() || fs::rename(&output_path, &draft_path).is_err() { - 631
let _ = fs::remove_dir_all(&staging_root); - 632
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 633
} - 634
let frozen_root = super::sandbox_candidates_root(state).join(&id); - 635
let candidate = match vak_sandbox::freeze_revision_candidate( - 636
&id, - 637
&staging_root, - 638
&parent.candidate, - 639
&frozen_root, - 640
) { - 641
Ok(c) => c, - 642
Err(e) => { - 643
let _ = fs::remove_dir_all(&staging_root); - 644
return ( - 645
StatusCode::UNPROCESSABLE_ENTITY, - 646
Json(serde_json::json!({"error":e.to_string()})), - 647
) - 648
.into_response(); - 649
} - 650
}; - 651
let _ = fs::remove_dir_all(&staging_root); - 652
let mut candidate = candidate; - 653
candidate.target_checks = vak_sandbox::default_target_verifiers().plan(&candidate); - 654
candidate.workspace_checks = super::planned_workspace_checks(&candidate); - 655
let draft_checks = vak_tools::broker::verify_targets( - 656
&state.core.tool_worker_exe(), - 657
&candidate.source_root, - 658
&candidate.target_checks, - 659
) - 660
.await; - 661
let digest = match vak_sandbox::candidate_digest(&candidate) { - 662
Ok(d) => d, - 663
Err(_) => { - 664
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 665
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 666
} - 667
}; - 668
let saved = vak_sandbox::CandidateRecord { - 669
record_id: format!("candidate-{id}"), - 670
session_id: parent.session_id.clone(), - 671
turn_id: parent.turn_id.clone(), - 672
result_id: parent.result_id.clone(), - 673
execution_id: parent.execution_id.clone(), - 674
environment_id: "office-workspace".into(), - 675
candidate_digest: digest, - 676
candidate, - 677
verified: true, - 678
draft_checks, - 679
updated_at: chrono::Utc::now().to_rfc3339(), - 680
parent_candidate_id: Some(parent_id.clone()), - 681
revision_session_id: None, - 682
narrowed: None, - 683
}; - 684
if vak_sandbox::append_record( - 685
&super::sandbox_records_path(state), - 686
&vak_sandbox::DurableRecord::Candidate(saved.clone()), - 687
) - 688
.is_err() - 689
{ - 690
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 691
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 692
} - 693
room.branches[branch_pos].head_candidate_id = id.clone(); - 694
room.revisions.push(OfficeRevision { - 695
candidate_id: id, - 696
parent_candidate_id: parent_id, - 697
merge_parent_candidate_id: merge_parent, - 698
branch_id, - 699
author_id: actor_id, - 700
author_name: actor_name, - 701
created_at: chrono::Utc::now().to_rfc3339(), - 702
ops, - 703
}); - 704
if save(path, &room).is_err() { - 705
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 706
} - 707
if let Some(handle) = state.get(&session_id) { - 708
let _ = handle.coworking_comments_tx.send(()); - 709
} - 710
Json(serde_json::json!({"workspace":room,"candidate":saved,"worker_result":applied})) - 711
.into_response() - 712
} - 713
- 714
fn operations_since( - 715
room: &OfficeRoom, - 716
head: &str, - 717
base: &str, - 718
) -> Option<Vec<vak_ooxml::edit::OfficeOp>> { - 719
let mut cursor = head; - 720
let mut result = Vec::new(); - 721
for _ in 0..room.revisions.len() { - 722
if cursor == base { - 723
result.reverse(); - 724
return Some(result.into_iter().flatten().collect()); - 725
} - 726
let revision = room - 727
.revisions - 728
.iter() - 729
.rev() - 730
.find(|r| r.candidate_id == cursor)?; - 731
result.push(revision.ops.clone()); - 732
cursor = &revision.parent_candidate_id; - 733
} - 734
None - 735
} - 736
- 737
fn op_keys(ops: &[vak_ooxml::edit::OfficeOp]) -> Vec<String> { - 738
use vak_ooxml::edit::OfficeOp as O; - 739
let mut keys = Vec::new(); - 740
for op in ops { - 741
match op { - 742
O::ReplaceParagraphText { anchor, .. } - 743
| O::DeleteParagraph { anchor } - 744
| O::SetPlaceholderText { anchor, .. } - 745
| O::SetNotes { anchor, .. } - 746
| O::DeleteSlide { anchor } - 747
| O::MoveSlide { anchor, .. } => keys.push(format!("a:{anchor}")), - 748
O::AddParagraph { after, .. } | O::AddTable { after, .. } => keys.push(match after { - 749
Some(anchor) => format!("a:{anchor}"), - 750
None => "doc:end".into(), - 751
}), - 752
O::SetCells { sheet, cells } => { - 753
keys.extend(cells.keys().map(|cell| format!("c:{sheet}!{cell}"))) - 754
} - 755
O::AppendRows { sheet, .. } => keys.push(format!("s:{sheet}:rows")), - 756
O::AddSheet { name: sheet } => keys.push(format!("s:{sheet}:create")), - 757
// A rename touches everything on the sheet under either name. - 758
O::RenameSheet { sheet, name } => { - 759
keys.push(format!("s:{sheet}:create")); - 760
keys.push(format!("s:{name}:create")); - 761
} - 762
O::FormatCells { sheet, .. } => keys.push(format!("s:{sheet}:format")), - 763
O::SetColumnWidths { sheet, .. } => keys.push(format!("s:{sheet}:columns")), - 764
O::AddSlideFromLayout { .. } => keys.push("deck:slides".into()), - 765
O::SetTitle { .. } => keys.push("meta:title".into()), - 766
} - 767
} - 768
keys - 769
} - 770
- 771
fn has_conflict( - 772
shared: &[vak_ooxml::edit::OfficeOp], - 773
branch: &[vak_ooxml::edit::OfficeOp], - 774
) -> bool { - 775
let a = op_keys(shared); - 776
let b = op_keys(branch); - 777
use vak_ooxml::edit::OfficeOp as O; - 778
let changes_paragraph_order = |ops: &[O]| { - 779
ops.iter().any(|op| { - 780
matches!( - 781
op, - 782
O::AddParagraph { .. } | O::AddTable { .. } | O::DeleteParagraph { .. } - 783
) - 784
}) - 785
}; - 786
let word = |ops: &[O]| { - 787
ops.iter().any(|op| { - 788
matches!( - 789
op, - 790
O::ReplaceParagraphText { .. } - 791
| O::AddParagraph { .. } - 792
| O::AddTable { .. } - 793
| O::DeleteParagraph { .. } - 794
) - 795
}) - 796
}; - 797
let (shared_word, branch_word) = (word(shared), word(branch)); - 798
(changes_paragraph_order(shared) && branch_word - 799
|| changes_paragraph_order(branch) && shared_word) - 800
|| a.iter().any(|left| { - 801
b.iter().any(|right| { - 802
left == right - 803
|| (left == "deck:slides" && right.starts_with("a:slide:")) - 804
|| (right == "deck:slides" && left.starts_with("a:slide:")) - 805
|| (left.starts_with("s:") - 806
&& right.starts_with("s:") - 807
&& left.split(':').nth(1) == right.split(':').nth(1) - 808
&& (left.ends_with(":create") || right.ends_with(":create"))) - 809
|| (left.starts_with("c:") - 810
&& right.starts_with("s:") - 811
&& left - 812
.strip_prefix("c:") - 813
.and_then(|cell| cell.split_once('!')) - 814
.is_some_and(|(sheet, _)| { - 815
right - 816
.strip_prefix("s:") - 817
.is_some_and(|rows| rows.starts_with(&format!("{sheet}:"))) - 818
})) - 819
|| (right.starts_with("c:") - 820
&& left.starts_with("s:") - 821
&& right - 822
.strip_prefix("c:") - 823
.and_then(|cell| cell.split_once('!')) - 824
.is_some_and(|(sheet, _)| { - 825
left.strip_prefix("s:") - 826
.is_some_and(|rows| rows.starts_with(&format!("{sheet}:"))) - 827
})) - 828
}) - 829
}) - 830
} - 831
- 832
#[cfg(test)] - 833
mod tests { - 834
use super::*; - 835
use vak_ooxml::edit::OfficeOp as O; - 836
- 837
#[test] - 838
fn overlapping_cells_and_sheet_creation_conflict() { - 839
let left = vec![O::SetCells { - 840
sheet: "Sheet 1".into(), - 841
cells: [("A1".into(), vak_ooxml::edit::CellValue::Text("one".into()))] - 842
.into_iter() - 843
.collect(), - 844
}]; - 845
let same = vec![O::SetCells { - 846
sheet: "Sheet 1".into(), - 847
cells: [("A1".into(), vak_ooxml::edit::CellValue::Text("two".into()))] - 848
.into_iter() - 849
.collect(), - 850
}]; - 851
assert!(has_conflict(&left, &same)); - 852
let create = vec![O::AddSheet { - 853
name: "Sheet 1".into(), - 854
}]; - 855
assert!(has_conflict(&left, &create)); - 856
} - 857
- 858
#[test] - 859
fn inserting_a_paragraph_conflicts_with_other_word_edits() { - 860
let insert = vec![O::AddParagraph { - 861
text: "new".into(), - 862
style: None, - 863
after: Some("p@1".into()), - 864
}]; - 865
let edit = vec![O::ReplaceParagraphText { - 866
anchor: "p@8".into(), - 867
text: "changed".into(), - 868
}]; - 869
assert!(has_conflict(&insert, &edit)); - 870
} - 871
} - 872
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.