- 1
//! `office_apply`: propose edits to, or create, a Word, Excel or PowerPoint - 2
//! file through the typed op set (docs/design/72-openxml-documents.md, P2). - 3
//! - 4
//! It runs in the broker worker (invariant 14). An edited source must be - 5
//! the exact file the model read (`base_digest`, O4); a new file with no - 6
//! source starts from Vakyartha's built-in blank ("Creating from scratch"). - 7
//! Every op is checked by a re-read before anything is written (O5), and - 8
//! Word edits to an existing file are tracked changes under the runtime's - 9
//! Agent id, never a name the model chose. - 10
//! - 11
//! It never writes the workspace file. The result is a draft in this - 12
//! execution's `.vak/scratch/<agent>/<execution>/` directory, announced to - 13
//! the Workbench like any other execution, so the one Review path (freeze a - 14
//! candidate, verify it in the worker, promote atomically with undo) is how - 15
//! a change reaches the workspace (invariant 35; docs/design/72, "Owner - 16
//! direction"). - 17
- 18
use std::path::{Path, PathBuf}; - 19
use std::sync::LazyLock; - 20
- 21
use async_trait::async_trait; - 22
use serde_json::Value; - 23
- 24
use crate::broker::OfficeOrigin; - 25
use crate::{ResourceClaims, Tool, ToolContext, ToolOutput}; - 26
- 27
/// What a model is told: how to create a file from scratch, from a - 28
/// template, and how to edit one. The blank's styles, sheet and layouts - 29
/// come from the blank itself, since there is nothing to read first. - 30
static DESCRIPTION: LazyLock<String> = LazyLock::new(|| { - 31
format!( - 32
"Create, or propose edits to, a Word, Excel or PowerPoint file with typed ops. The result is a draft a person reviews and accepts; the workspace does not change until then. \ - 33
To create a new file from scratch, give path (a new .docx, .xlsx or .pptx name) and ops, and leave out source and base_digest. A new Word document offers the styles {styles}; add content with add_paragraph and add_table. A new workbook has one empty sheet, {sheet}; use rename_sheet, set_cells, format_cells and set_column_widths. A new deck has the layouts {layouts}; add slides with add_slide_from_layout, with notes if wanted. \ - 34
To create a file from a template in the workspace, set source to the template, base_digest to its sha256, and path to the new file. \ - 35
To edit a file, read it with doc_read first and pass the sha256 it printed as base_digest; ops name anchors from that read (p@12, p:1A2B3C4D, a table cell's paragraph as its row shows it, Budget!B4, slide:256/shape:3, slide:256/placeholder:title). Word edits to an existing file become tracked changes; a new file is written clean. \ - 36
Text is plain: Markdown is not interpreted, so headings and lists come from styles. Excel calculates formulas when the file is opened. To keep editing a draft, pass the draft as source with its sha256. Macros are never added or run. A Visio drawing cannot be created or edited: say so, and never build one with a command or script.", - 37
styles = vak_ooxml::blank::DOCUMENT_STYLES.join(", "), - 38
sheet = vak_ooxml::blank::WORKBOOK_SHEET, - 39
layouts = vak_ooxml::blank::DECK_LAYOUTS - 40
.iter() - 41
.map(|(layout, placeholders)| format!("{layout} ({placeholders})")) - 42
.collect::<Vec<_>>() - 43
.join(", "), - 44
) - 45
}); - 46
- 47
pub struct OfficeApplyTool; - 48
- 49
#[async_trait] - 50
impl Tool for OfficeApplyTool { - 51
fn name(&self) -> &str { - 52
"office_apply" - 53
} - 54
- 55
fn serves(&self) -> &'static [&'static str] { - 56
&["documents"] - 57
} - 58
- 59
fn delivered_file(&self, args: &Value) -> Option<String> { - 60
args.get("path").and_then(Value::as_str).map(str::to_string) - 61
} - 62
- 63
fn description(&self) -> &str { - 64
DESCRIPTION.as_str() - 65
} - 66
- 67
fn schema(&self) -> Value { - 68
serde_json::json!({ - 69
"type": "object", - 70
"properties": { - 71
"path": { - 72
"type": "string", - 73
"description": "The workspace file the draft is for (relative to the workspace): an existing file to edit, or a new name to create. May equal source." - 74
}, - 75
"source": { - 76
"type": "string", - 77
"description": "File to start from: the file being edited, a template, or an earlier draft. Defaults to path. Leave out, with base_digest, to create path from scratch." - 78
}, - 79
"base_digest": { - 80
"type": "string", - 81
"description": "The sha256 exactly as doc_read printed it for the source file (its first 16 characters, as shown, are enough; never fill in the rest), proving the ops were written against its current content. Leave out only when creating a new file from scratch." - 82
}, - 83
"ops": { - 84
"type": "array", - 85
"minItems": 1, - 86
"description": "Ops applied in order. Each names its `op` and only that op's fields. Anchors come from doc_read of the source: paragraphs p:1A2B3C4D or p@12, cells Budget!B4, slides slide:256, shapes slide:256/shape:3, placeholders slide:256/placeholder:title.", - 87
"items": { "oneOf": op_schemas() } - 88
} - 89
}, - 90
"required": ["path", "ops"] - 91
}) - 92
} - 93
- 94
fn claims(&self, args: &Value) -> ResourceClaims { - 95
ResourceClaims { - 96
exclusive: false, - 97
read_only: false, - 98
paths: ["path", "source"] - 99
.iter() - 100
.filter_map(|key| args.get(*key).and_then(Value::as_str)) - 101
.map(str::to_string) - 102
.collect(), - 103
} - 104
} - 105
- 106
async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { - 107
let Some(path) = args.get("path").and_then(Value::as_str).map(str::trim) else { - 108
return ToolOutput::error("missing required parameter: path"); - 109
}; - 110
let named_source = args - 111
.get("source") - 112
.and_then(Value::as_str) - 113
.map(str::trim) - 114
.filter(|source| !source.is_empty()); - 115
let base_digest = args - 116
.get("base_digest") - 117
.and_then(Value::as_str) - 118
.map(str::trim) - 119
.filter(|digest| !digest.is_empty()); - 120
let ops: Vec<vak_ooxml::edit::OfficeOp> = match args.get("ops") { - 121
Some(ops) => match serde_json::from_value(ops.clone()) { - 122
Ok(ops) => ops, - 123
Err(error) => { - 124
return ToolOutput::error(format!( - 125
"ops are not valid: {error}. Each op is an object with an \"op\" name and only that op's fields" - 126
)); - 127
} - 128
}, - 129
None => return ToolOutput::error("missing required parameter: ops"), - 130
}; - 131
let destination = match confined_destination(&ctx.cwd, path) { - 132
Ok(destination) => destination, - 133
Err(error) => return ToolOutput::error(error), - 134
}; - 135
// One way to create: a new path with no source and no digest starts - 136
// from the built-in blank. A digest for a file that does not exist - 137
// means the model thought it was editing one (a mistyped name), so - 138
// it is never taken as a request to create. - 139
let origin = match (named_source, base_digest) { - 140
(None, None) if destination.exists() => { - 141
return ToolOutput::error(format!( - 142
"{path} already exists. To change it, read it with doc_read and pass the sha256 it prints as base_digest; to create a new file from scratch, give a name that does not exist yet" - 143
)); - 144
} - 145
(None, None) => OfficeOrigin::Blank, - 146
(Some(source), None) => { - 147
return ToolOutput::error(format!( - 148
"missing base_digest for source {source}: read it with doc_read and pass the sha256 it prints. To create a new file from scratch, leave out source as well" - 149
)); - 150
} - 151
(source, Some(digest)) => { - 152
let source = source.unwrap_or(path); - 153
match confined_existing(&ctx.cwd, source) { - 154
Ok(source) => OfficeOrigin::File { - 155
path: source, - 156
base_digest: digest.to_string(), - 157
}, - 158
Err(_) if source == path && !destination.exists() => { - 159
return ToolOutput::error(format!( - 160
"{path} does not exist, so there is no file for base_digest to name. To create it from scratch, leave out base_digest; to edit a file, check its name" - 161
)); - 162
} - 163
Err(error) => return ToolOutput::error(error), - 164
} - 165
} - 166
}; - 167
let Some(target) = destination - 168
.extension() - 169
.and_then(|extension| extension.to_str()) - 170
.and_then(vak_ooxml::Format::from_extension) - 171
else { - 172
return ToolOutput::error(format!( - 173
"{path} is not named as a Word, Excel or PowerPoint file (.docx, .xlsx, .pptx and their variants)" - 174
)); - 175
}; - 176
let root = match canonical_root(&ctx.cwd) { - 177
Ok(root) => root, - 178
Err(error) => return ToolOutput::error(error), - 179
}; - 180
let Ok(relative) = destination.strip_prefix(&root).map(Path::to_path_buf) else { - 181
return ToolOutput::error(format!("access denied: {path} is outside the workspace")); - 182
}; - 183
if relative.starts_with(".vak") { - 184
return ToolOutput::error(format!( - 185
"{path} is inside .vak; name the workspace file the draft is for, and pass an earlier draft as source" - 186
)); - 187
} - 188
let agent = ctx.agent_id.as_deref(); - 189
let author = tracked_change_author(agent.unwrap_or(DEFAULT_AGENT)); - 190
let now = chrono::Utc::now(); - 191
let date = now.format("%Y-%m-%dT%H:%M:%SZ").to_string(); - 192
let execution = ctx - 193
.sandbox_sink - 194
.as_ref() - 195
.map(|sink| sink.execution_id().to_string()) - 196
.unwrap_or_else(|| format!("office-{}", now.timestamp_nanos_opt().unwrap_or(0))); - 197
let draft_root = root.join(draft_dir(agent, &execution)); - 198
let draft = draft_root.join(&relative); - 199
let draft_relative = draft - 200
.strip_prefix(&root) - 201
.unwrap_or(&draft) - 202
.display() - 203
.to_string(); - 204
let started = std::time::Instant::now(); - 205
if let Some(sink) = &ctx.sandbox_sink { - 206
let preview = serde_json::json!({ - 207
"path": path, - 208
"source": named_source.unwrap_or(if origin == OfficeOrigin::Blank { "blank" } else { path }), - 209
"ops": args.get("ops"), - 210
}) - 211
.to_string(); - 212
sink.emit_execution_started( - 213
"office_apply", - 214
&preview, - 215
"json", - 216
&draft_root.display().to_string(), - 217
); - 218
} - 219
// A file not in the workspace yet is a new document, written clean; - 220
// an existing one is edited with tracked changes (docs/design/72, R7). - 221
let relative_path = relative.to_string_lossy().replace('\\', "/"); - 222
let exists = destination.is_file(); - 223
let tracked = exists && !ctx.new_documents.contains(&relative_path); - 224
let job = Job { - 225
origin, - 226
destination, - 227
draft: draft.clone(), - 228
ops, - 229
context: vak_ooxml::edit::EditContext { - 230
author, - 231
date, - 232
tracked, - 233
}, - 234
target, - 235
}; - 236
let work = tokio::task::spawn_blocking(move || job.run()).await; - 237
let duration = started.elapsed().as_millis() as u64; - 238
let finish = |code: i32, artifacts: Vec<String>| { - 239
if let Some(sink) = &ctx.sandbox_sink { - 240
sink.emit_finished(code, duration, artifacts); - 241
} - 242
}; - 243
match work { - 244
Ok(Ok(report)) => { - 245
crate::artifact::emit_file(ctx.sandbox_sink.as_ref(), &draft, &root); - 246
finish(0, vec![draft_relative.clone()]); - 247
let state = if exists { - 248
format!("{path} in the workspace is unchanged") - 249
} else { - 250
format!("{path} is a new file, not in the workspace") - 251
}; - 252
ToolOutput::ok(format!( - 253
"Draft for {path} written to {draft_relative}. {state} until a person reviews and accepts the draft: the draft is already shown to them with Review draft and its change list, so do not present it again as a card, HTML or a diff, and never copy, move or rename the draft into the workspace yourself: that would skip the person's review. Answer with one sentence saying what you changed, and stop. To keep editing, call office_apply again with source \"{draft_relative}\" and the draft's sha256 as base_digest.\n{report}" - 254
)) - 255
} - 256
Ok(Err(error)) => { - 257
finish(1, Vec::new()); - 258
ToolOutput::error(error) - 259
} - 260
Err(error) => { - 261
finish(1, Vec::new()); - 262
ToolOutput::error(format!("office_apply failed: {error}")) - 263
} - 264
} - 265
} - 266
} - 267
- 268
/// The refusal a text tool gives for an Office file: a package is a ZIP of - 269
/// XML parts, so a text read shows nothing useful and a text edit or write - 270
/// can only fail or destroy it. Names the tools that do handle it. - 271
pub fn text_tool_refusal(path: &Path, tool: &str) -> Option<String> { - 272
let name = path.to_string_lossy(); - 273
if !vak_ooxml::is_openxml_path(&name) { - 274
return None; - 275
} - 276
let what = path - 277
.extension() - 278
.and_then(|extension| extension.to_str()) - 279
.and_then(vak_ooxml::Format::from_extension) - 280
.map(|format| format.vocabulary.with_article()) - 281
.unwrap_or("an Office file"); - 282
let display = path - 283
.file_name() - 284
.map(|file| file.to_string_lossy().into_owned()) - 285
.unwrap_or_else(|| name.into_owned()); - 286
Some(match tool { - 287
"read" => format!( - 288
"{display} is {what}, a ZIP package that {tool} cannot show. Read it with doc_read, which returns its text with anchors and a sha256." - 289
), - 290
_ => format!( - 291
"{display} is {what}, a ZIP package that {tool} would corrupt; nothing was changed. Read it with doc_read, then change it with office_apply, which writes a draft for review." - 292
), - 293
}) - 294
} - 295
- 296
/// The Agent a draft is filed under when the session names none. - 297
const DEFAULT_AGENT: &str = "vak"; - 298
- 299
/// Where one execution's drafts live, relative to the workspace root: - 300
/// `.vak/scratch/<agent>/<execution>/`, the draft of a file keeping the - 301
/// file's workspace path beneath it. The execution is the `office_apply` - 302
/// call's id and the agent is the session's Agent id, so the ledger alone - 303
/// locates every draft a session delivered. - 304
pub fn draft_dir(agent_id: Option<&str>, execution: &str) -> PathBuf { - 305
Path::new(".vak") - 306
.join("scratch") - 307
.join(agent_id.unwrap_or(DEFAULT_AGENT)) - 308
.join(execution) - 309
} - 310
- 311
/// The name Word shows on an Agent's tracked changes: the runtime's Agent - 312
/// id, never a name the model chose. - 313
pub fn tracked_change_author(agent_id: &str) -> String { - 314
if agent_id == "vak" { - 315
"Vakyartha".to_string() - 316
} else { - 317
agent_id.to_string() - 318
} - 319
} - 320
- 321
/// One schema branch per op, so a model sees each op's exact fields and a - 322
/// malformed op is reported against the op it names (`contract.rs`). - 323
fn op_schemas() -> Value { - 324
fn op(name: &str, fields: Value, required: &[&str], description: &str) -> Value { - 325
let mut properties = serde_json::json!({ "op": { "type": "string", "enum": [name] } }); - 326
if let (Some(target), Some(extra)) = (properties.as_object_mut(), fields.as_object()) { - 327
target.extend(extra.clone()); - 328
} - 329
let mut all_required = vec!["op"]; - 330
all_required.extend_from_slice(required); - 331
serde_json::json!({ - 332
"type": "object", - 333
"description": description, - 334
"properties": properties, - 335
"required": all_required, - 336
"additionalProperties": false - 337
}) - 338
} - 339
let anchor = |what: &str| serde_json::json!({ "type": "string", "description": what }); - 340
let text = serde_json::json!({ "type": "string" }); - 341
let lines = serde_json::json!({ - 342
"type": ["string", "array"], - 343
"items": { "type": "string" }, - 344
"description": "One line, or an array of lines (one bullet or paragraph each)" - 345
}); - 346
serde_json::json!([ - 347
op( - 348
"replace_paragraph_text", - 349
serde_json::json!({ "anchor": anchor("paragraph anchor, e.g. p@12"), "text": text }), - 350
&["anchor", "text"], - 351
"Word: give the paragraph's whole new text, as it should read. Only the words that differ become tracked changes; its formatting, links, footnote marks and fields stay as they are" - 352
), - 353
op( - 354
"add_paragraph", - 355
serde_json::json!({ "text": text, "style": { "type": "string", "description": "style id or name, e.g. Heading 1, List Bullet, List Number" }, "after": anchor("paragraph anchor to add after; omit to add at the end of the document") }), - 356
&["text"], - 357
"Word: add a paragraph, at the end or after one. A List Number paragraph continues the list just above it, else starts at 1" - 358
), - 359
op( - 360
"add_table", - 361
serde_json::json!({ "rows": { "type": "array", "items": { "type": "array" }, "description": "rows of cell text, the first row being the header, e.g. [[\"Region\", \"Sales\"], [\"North\", \"120\"]]" }, "after": anchor("paragraph anchor to add after; omit to add at the end"), "header": { "type": "boolean", "description": "false when the first row is not a header row; default true" } }), - 362
&["rows"], - 363
"Word: add a table spanning the page width" - 364
), - 365
op( - 366
"delete_paragraph", - 367
serde_json::json!({ "anchor": anchor("paragraph anchor") }), - 368
&["anchor"], - 369
"Word: delete a paragraph (a tracked change in an existing file; removed outright in a new one)" - 370
), - 371
op( - 372
"set_cells", - 373
serde_json::json!({ "sheet": { "type": "string" }, "cells": { "type": "object", "description": "address to value, e.g. {\"B4\": 120, \"C4\": \"=SUM(B1:B3)\"}" } }), - 374
&["sheet", "cells"], - 375
"Excel: set cell values or formulas" - 376
), - 377
op( - 378
"append_rows", - 379
serde_json::json!({ "sheet": { "type": "string" }, "rows": { "type": "array", "items": { "type": "array" } } }), - 380
&["sheet", "rows"], - 381
"Excel: append rows after the last used row" - 382
), - 383
op( - 384
"add_sheet", - 385
serde_json::json!({ "name": { "type": "string" } }), - 386
&["name"], - 387
"Excel: add an empty sheet" - 388
), - 389
op( - 390
"rename_sheet", - 391
serde_json::json!({ "sheet": { "type": "string" }, "name": { "type": "string", "description": "the new name" } }), - 392
&["sheet", "name"], - 393
"Excel: rename a sheet, before anything refers to it by name" - 394
), - 395
op( - 396
"format_cells", - 397
serde_json::json!({ "sheet": { "type": "string" }, "range": { "type": "string", "description": "cells, e.g. A1:D1 or B4" }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "number_format": { "type": "string", "description": "an Excel format code, e.g. #,##0.00 or 0% or yyyy-mm-dd" }, "fill": { "type": "string", "description": "background colour as six hex digits, e.g. D9E2F3" }, "wrap": { "type": "boolean", "description": "wrap long text" } }), - 398
&["sheet", "range"], - 399
"Excel: format cells; every other part of each cell's format is kept" - 400
), - 401
op( - 402
"set_column_widths", - 403
serde_json::json!({ "sheet": { "type": "string" }, "widths": { "type": "object", "description": "column letter to width in characters, e.g. {\"A\": 30, \"B\": 12}" } }), - 404
&["sheet", "widths"], - 405
"Excel: set column widths" - 406
), - 407
op( - 408
"add_slide_from_layout", - 409
serde_json::json!({ "layout": { "type": "string", "description": "layout name, e.g. Title and Content" }, "after": anchor("slide anchor to insert after, e.g. slide:256; omit to add at the end"), "placeholders": { "type": "object", "description": "placeholder to text, e.g. {\"title\": \"Next steps\", \"body\": [\"First\", \"Second\"]}; a Title Slide has title and subtitle, Two Content has idx:1 and idx:2" }, "notes": { "type": "string", "description": "speaker notes for the slide" } }), - 410
&["layout"], - 411
"PowerPoint: add a slide from one of the deck's layouts" - 412
), - 413
op( - 414
"set_placeholder_text", - 415
serde_json::json!({ "anchor": anchor("placeholder or shape anchor, e.g. slide:256/placeholder:title"), "text": lines }), - 416
&["anchor", "text"], - 417
"PowerPoint: set a placeholder's or shape's text" - 418
), - 419
op( - 420
"set_notes", - 421
serde_json::json!({ "anchor": anchor("slide anchor"), "text": text }), - 422
&["anchor", "text"], - 423
"PowerPoint: set a slide's speaker notes" - 424
), - 425
op( - 426
"delete_slide", - 427
serde_json::json!({ "anchor": anchor("slide anchor") }), - 428
&["anchor"], - 429
"PowerPoint: delete a slide" - 430
), - 431
op( - 432
"move_slide", - 433
serde_json::json!({ "anchor": anchor("slide anchor"), "after": anchor("slide anchor to move after; omit to move to the start") }), - 434
&["anchor"], - 435
"PowerPoint: move a slide" - 436
), - 437
op( - 438
"set_title", - 439
serde_json::json!({ "title": text }), - 440
&["title"], - 441
"Any: set the document title property" - 442
), - 443
]) - 444
} - 445
- 446
struct Job { - 447
origin: OfficeOrigin, - 448
destination: PathBuf, - 449
draft: PathBuf, - 450
ops: Vec<vak_ooxml::edit::OfficeOp>, - 451
context: vak_ooxml::edit::EditContext, - 452
target: vak_ooxml::Format, - 453
} - 454
- 455
/// Applies `ops` to where `origin` starts: a file, refused unless its - 456
/// `base_digest` names its bytes, or the built-in blank for `target`. The - 457
/// one path every Office edit and creation takes, from the `office_apply` - 458
/// tool and from `vak office apply`. Returns the starting bytes and what - 459
/// the engine wrote; nothing is written here. - 460
pub(crate) fn apply_checked( - 461
origin: &OfficeOrigin, - 462
ops: &[vak_ooxml::edit::OfficeOp], - 463
context: &vak_ooxml::edit::EditContext, - 464
target: vak_ooxml::Format, - 465
) -> Result<(Vec<u8>, vak_ooxml::edit::Applied), String> { - 466
let limits = vak_ooxml::Limits::default(); - 467
let (source, base_digest) = match origin { - 468
OfficeOrigin::File { path, base_digest } => (path, base_digest), - 469
OfficeOrigin::Blank => { - 470
let bytes = vak_ooxml::blank::blank(target) - 471
.map_err(|error| format!("nothing was written: {error}"))?; - 472
vak_ooxml::edit::check_renumbering(ops, context) - 473
.map_err(|error| format!("nothing was written: {error}"))?; - 474
let applied = vak_ooxml::edit::apply(&bytes, ops, context, limits, Some(target)) - 475
.map_err(|error| format!("nothing was written: {error}"))?; - 476
return Ok((bytes, applied)); - 477
} - 478
}; - 479
let bytes = read_bounded(source, &limits)?; - 480
let digest = sha256_hex(&bytes); - 481
let base_digest = base_digest - 482
.trim() - 483
.trim_end_matches('…') - 484
.to_ascii_lowercase(); - 485
if base_digest.len() < 16 || !digest.starts_with(&base_digest) { - 486
return Err(format!( - 487
"base_digest {base_digest:?} does not match the source (sha256 {}…); the file changed since it was read or the digest was mistyped. Pass the sha256 exactly as the read shows it, `{}…`, without completing it; if the file changed, read it again (doc_read, or `vak office read`) and use the anchors and sha256 from that read", - 488
&digest[..16], - 489
&digest[..16] - 490
)); - 491
} - 492
vak_ooxml::edit::check_renumbering(ops, context) - 493
.map_err(|error| format!("nothing was written: {error}"))?; - 494
let applied = vak_ooxml::edit::apply(&bytes, ops, context, limits, Some(target)) - 495
.map_err(|error| format!("nothing was written: {error}"))?; - 496
Ok((bytes, applied)) - 497
} - 498
- 499
impl Job { - 500
fn run(self) -> Result<String, String> { - 501
let limits = vak_ooxml::Limits::default(); - 502
let (_, applied) = apply_checked(&self.origin, &self.ops, &self.context, self.target)?; - 503
let current = if self.destination.is_file() { - 504
let bytes = read_bounded(&self.destination, &limits)?; - 505
Some( - 506
vak_ooxml::read::read(std::io::Cursor::new(bytes), limits).map_err(|error| { - 507
format!("the current workspace file cannot be read for comparison: {error}") - 508
})?, - 509
) - 510
} else { - 511
None - 512
}; - 513
let changes = vak_ooxml::diff::diff(current.as_ref(), &applied.document); - 514
if let Some(parent) = self.draft.parent() { - 515
std::fs::create_dir_all(parent) - 516
.map_err(|error| format!("cannot create the draft directory: {error}"))?; - 517
} - 518
write_atomically(&self.draft, &applied.bytes)?; - 519
let mut report = format!("Draft sha256 {}….\n", &sha256_hex(&applied.bytes)[..16]); - 520
for result in &applied.results { - 521
report.push_str(&format!( - 522
"- {}: {} ({})\n", - 523
result.op, result.summary, result.check - 524
)); - 525
} - 526
for notice in &applied.notices { - 527
report.push_str(&format!("Note: {notice}.\n")); - 528
} - 529
match (¤t, changes.changes.first()) { - 530
(None, Some(change)) => { - 531
let facts = change.after.as_deref().unwrap_or("new file"); - 532
let facts = facts.strip_prefix("new file: ").unwrap_or(facts); - 533
report.push_str(&format!("It is a new file: {facts}.\n")); - 534
} - 535
_ if changes.summary.is_empty() => { - 536
report.push_str("Compared with the workspace file: no visible change.\n") - 537
} - 538
_ => { - 539
report.push_str("Compared with the workspace file: "); - 540
report.push_str(&changes.summary.join("; ")); - 541
report.push('\n'); - 542
} - 543
} - 544
match self.target.vocabulary { - 545
vak_ooxml::Vocabulary::Word if self.context.tracked => report.push_str(&format!( - 546
"Word edits are tracked changes by {}; they can also be accepted or rejected in Word.\n", - 547
self.context.author - 548
)), - 549
vak_ooxml::Vocabulary::Excel => report.push_str( - 550
"Vakyartha does not calculate formulas: Excel calculates them when the file is opened, and until then a formula shows a stale value or none.\n", - 551
), - 552
_ => {} - 553
} - 554
Ok(report) - 555
} - 556
} - 557
- 558
pub(crate) fn read_bounded(path: &Path, limits: &vak_ooxml::Limits) -> Result<Vec<u8>, String> { - 559
let size = std::fs::metadata(path) - 560
.map_err(|error| format!("cannot read {}: {error}", path.display()))? - 561
.len(); - 562
if size > limits.max_total_bytes { - 563
return Err(format!( - 564
"{} is over the size limit for an Office package", - 565
path.display() - 566
)); - 567
} - 568
std::fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display())) - 569
} - 570
- 571
pub(crate) fn sha256_hex(bytes: &[u8]) -> String { - 572
use sha2::Digest as _; - 573
sha2::Sha256::digest(bytes) - 574
.iter() - 575
.map(|byte| format!("{byte:02x}")) - 576
.collect() - 577
} - 578
- 579
/// Writes through a sibling temporary file and a rename, so a reader never - 580
/// sees half a package and a failure leaves no partial file. - 581
pub(crate) fn write_atomically(destination: &Path, bytes: &[u8]) -> Result<(), String> { - 582
use std::io::Write as _; - 583
let Some(parent) = destination.parent() else { - 584
return Err("the destination has no parent directory".into()); - 585
}; - 586
let name = destination - 587
.file_name() - 588
.map(|name| name.to_string_lossy().into_owned()) - 589
.unwrap_or_default(); - 590
let temporary = parent.join(format!(".{name}.vak-{}.tmp", std::process::id())); - 591
let result = std::fs::OpenOptions::new() - 592
.write(true) - 593
.create_new(true) - 594
.open(&temporary) - 595
.and_then(|mut file| { - 596
file.write_all(bytes)?; - 597
file.sync_all() - 598
}) - 599
.and_then(|()| std::fs::rename(&temporary, destination)); - 600
if let Err(error) = result { - 601
let _ = std::fs::remove_file(&temporary); - 602
return Err(format!( - 603
"could not write {}: {error}", - 604
destination.display() - 605
)); - 606
} - 607
Ok(()) - 608
} - 609
- 610
fn canonical_root(cwd: &Path) -> Result<PathBuf, String> { - 611
cwd.canonicalize() - 612
.map_err(|error| format!("cannot resolve workspace root: {error}")) - 613
} - 614
- 615
/// An existing file inside the workspace (invariant 10). - 616
fn confined_existing(cwd: &Path, path: &str) -> Result<PathBuf, String> { - 617
let root = canonical_root(cwd)?; - 618
let candidate = if Path::new(path).is_absolute() { - 619
PathBuf::from(path) - 620
} else { - 621
cwd.join(path) - 622
}; - 623
let canonical = candidate - 624
.canonicalize() - 625
.map_err(|error| format!("cannot open {path}: {error}"))?; - 626
if !canonical.starts_with(&root) { - 627
return Err(format!("access denied: {path} is outside the workspace")); - 628
} - 629
Ok(canonical) - 630
} - 631
- 632
/// The workspace file a draft is for: its directory must exist inside the - 633
/// workspace, and it must not be a symlink. - 634
fn confined_destination(cwd: &Path, path: &str) -> Result<PathBuf, String> { - 635
let root = canonical_root(cwd)?; - 636
let candidate = if Path::new(path).is_absolute() { - 637
PathBuf::from(path) - 638
} else { - 639
cwd.join(path) - 640
}; - 641
let Some(name) = candidate.file_name() else { - 642
return Err(format!("{path} names no file")); - 643
}; - 644
let parent = candidate - 645
.parent() - 646
.ok_or_else(|| format!("{path} has no directory"))? - 647
.canonicalize() - 648
.map_err(|error| format!("the directory for {path} does not exist: {error}"))?; - 649
if !parent.starts_with(&root) { - 650
return Err(format!("access denied: {path} is outside the workspace")); - 651
} - 652
let destination = parent.join(name); - 653
if std::fs::symlink_metadata(&destination) - 654
.is_ok_and(|metadata| metadata.file_type().is_symlink()) - 655
{ - 656
return Err(format!( - 657
"{path} is a symlink; write to the file it points at instead" - 658
)); - 659
} - 660
Ok(destination) - 661
} - 662
- 663
#[cfg(test)] - 664
mod tests { - 665
#![allow(clippy::unwrap_used, clippy::expect_used)] - 666
use super::*; - 667
use crate::sandbox_events::{SandboxEvent, SandboxEventSink}; - 668
- 669
async fn run(dir: &Path, args: Value) -> ToolOutput { - 670
OfficeApplyTool - 671
.execute( - 672
&args, - 673
&ToolContext::new(dir.to_path_buf()).with_agent_id("mira"), - 674
) - 675
.await - 676
} - 677
- 678
fn digest_of(path: &Path) -> String { - 679
sha256_hex(&std::fs::read(path).unwrap())[..16].to_string() - 680
} - 681
- 682
fn draft_path(output: &ToolOutput) -> String { - 683
output - 684
.content - 685
.split("written to ") - 686
.nth(1) - 687
.and_then(|rest| rest.split(". ").next()) - 688
.unwrap() - 689
.to_string() - 690
} - 691
- 692
#[test] - 693
fn the_op_schema_accepts_exactly_what_the_engine_reads_and_names_the_fault() { - 694
let schema = OfficeApplyTool.schema(); - 695
let call = |ops: Value| serde_json::json!({"path": "a.docx", "base_digest": "0123456789abcdef", "ops": ops}); - 696
let valid = serde_json::json!([ - 697
{"op": "replace_paragraph_text", "anchor": "p@1", "text": "x"}, - 698
{"op": "add_paragraph", "text": "x", "style": "Heading 2", "after": "p@1"}, - 699
{"op": "add_paragraph", "text": "at the end"}, - 700
{"op": "add_table", "rows": [["Region", "Sales"], ["North", 120]], "header": true}, - 701
{"op": "delete_paragraph", "anchor": "p@1"}, - 702
{"op": "set_cells", "sheet": "Budget", "cells": {"B4": 1, "C4": "=A1", "D4": true}}, - 703
{"op": "append_rows", "sheet": "Budget", "rows": [["a", 1]]}, - 704
{"op": "add_sheet", "name": "Q4"}, - 705
{"op": "rename_sheet", "sheet": "Q4", "name": "Q4 plan"}, - 706
{"op": "format_cells", "sheet": "Budget", "range": "A1:D1", "bold": true, "number_format": "#,##0.00", "fill": "D9E2F3", "wrap": false, "italic": false}, - 707
{"op": "set_column_widths", "sheet": "Budget", "widths": {"A": 30, "B": 12.5}}, - 708
{"op": "add_slide_from_layout", "layout": "Title and Content", "after": "slide:256", "placeholders": {"title": "T", "body": ["a", "b"]}, "notes": "n"}, - 709
{"op": "set_placeholder_text", "anchor": "slide:256/placeholder:title", "text": ["a", "b"]}, - 710
{"op": "set_notes", "anchor": "slide:256", "text": "n"}, - 711
{"op": "delete_slide", "anchor": "slide:256"}, - 712
{"op": "move_slide", "anchor": "slide:256"}, - 713
{"op": "set_title", "title": "T"} - 714
]); - 715
crate::validate_input(&schema, &call(valid.clone())).unwrap(); - 716
let ops: Vec<vak_ooxml::edit::OfficeOp> = serde_json::from_value(valid).unwrap(); - 717
assert_eq!(ops.len(), 17, "every op the engine has, in the schema"); - 718
- 719
// The call a model made live: no `op`, and `after` as a boolean. - 720
let error = crate::validate_input( - 721
&schema, - 722
&call(serde_json::json!([{"after": true, "layout": "Title and Content", "placeholders": {"title": "Next steps"}}])), - 723
) - 724
.unwrap_err(); - 725
assert!( - 726
error.contains("needs `op`, one of: replace_paragraph_text, add_paragraph, add_table"), - 727
"{error}" - 728
); - 729
let error = crate::validate_input( - 730
&schema, - 731
&call(serde_json::json!([{"op": "add_slide_from_layout", "layout": "Title and Content", "after": true}])), - 732
) - 733
.unwrap_err(); - 734
assert!(error.contains("arguments.ops[0].after must be"), "{error}"); - 735
let error = crate::validate_input( - 736
&schema, - 737
&call(serde_json::json!([{"op": "add_slide", "layout": "x"}])), - 738
) - 739
.unwrap_err(); - 740
assert!(error.contains("\"add_slide\" is not one of"), "{error}"); - 741
} - 742
- 743
#[tokio::test] - 744
async fn text_tools_refuse_an_office_file_and_name_the_office_tools() { - 745
let dir = tempfile::tempdir().unwrap(); - 746
let file = dir.path().join("q3.docx"); - 747
let original = vak_ooxml::fixtures::docx(); - 748
std::fs::write(&file, &original).unwrap(); - 749
let ctx = ToolContext::new(dir.path().to_path_buf()); - 750
let read = crate::read::ReadTool - 751
.execute(&serde_json::json!({"path": "q3.docx"}), &ctx) - 752
.await; - 753
assert!(read.is_error); - 754
assert!( - 755
read.content.contains("a Word document") && read.content.contains("doc_read"), - 756
"{}", - 757
read.content - 758
); - 759
let edit = crate::edit::EditTool - 760
.execute( - 761
&serde_json::json!({"path": "q3.docx", "edits": [{"old_text": "Steady.", "new_text": "Growing."}]}), - 762
&ctx, - 763
) - 764
.await; - 765
assert!(edit.is_error); - 766
assert!( - 767
edit.content.contains("office_apply") && edit.content.contains("nothing was changed"), - 768
"{}", - 769
edit.content - 770
); - 771
let write = crate::write::WriteTool - 772
.execute( - 773
&serde_json::json!({"path": "q3.docx", "content": "Growing."}), - 774
&ctx, - 775
) - 776
.await; - 777
assert!(write.is_error); - 778
assert!(write.content.contains("office_apply"), "{}", write.content); - 779
assert_eq!( - 780
std::fs::read(&file).unwrap(), - 781
original, - 782
"the package is untouched" - 783
); - 784
- 785
std::fs::write(dir.path().join("notes.txt"), "plain").unwrap(); - 786
let plain = crate::read::ReadTool - 787
.execute(&serde_json::json!({"path": "notes.txt"}), &ctx) - 788
.await; - 789
assert!(!plain.is_error, "{}", plain.content); - 790
} - 791
- 792
#[tokio::test] - 793
async fn editing_a_signed_file_records_that_its_signature_was_removed() { - 794
let dir = tempfile::tempdir().unwrap(); - 795
let file = dir.path().join("memo.docx"); - 796
std::fs::write(&file, vak_ooxml::fixtures::signed_labelled_docx()).unwrap(); - 797
let output = run( - 798
dir.path(), - 799
serde_json::json!({ - 800
"path": "memo.docx", - 801
"base_digest": digest_of(&file), - 802
"ops": [{"op": "replace_paragraph_text", "anchor": "p@1", "text": "Hello again"}] - 803
}), - 804
) - 805
.await; - 806
assert!(!output.is_error, "{}", output.content); - 807
assert!( - 808
output - 809
.content - 810
.contains("Note: the source was digitally signed; its 1 signature(s) were removed"), - 811
"{}", - 812
output.content - 813
); - 814
} - 815
- 816
#[tokio::test] - 817
async fn an_edit_is_a_draft_and_the_workspace_file_is_untouched() { - 818
let dir = tempfile::tempdir().unwrap(); - 819
let file = dir.path().join("budget.xlsx"); - 820
std::fs::write(&file, vak_ooxml::fixtures::xlsx()).unwrap(); - 821
let (sink, mut events) = SandboxEventSink::new_with_id("exec-7".into()); - 822
let output = OfficeApplyTool - 823
.execute( - 824
&serde_json::json!({ - 825
"path": "budget.xlsx", - 826
"base_digest": digest_of(&file), - 827
"ops": [{"op": "set_cells", "sheet": "Budget", "cells": {"B2": 150}}] - 828
}), - 829
&ToolContext::new(dir.path().to_path_buf()) - 830
.with_agent_id("mira") - 831
.with_sandbox_sink(sink), - 832
) - 833
.await; - 834
assert!(!output.is_error, "{}", output.content); - 835
assert_eq!( - 836
std::fs::read(&file).unwrap(), - 837
vak_ooxml::fixtures::xlsx(), - 838
"the workspace file is unchanged" - 839
); - 840
assert_eq!(draft_path(&output), ".vak/scratch/mira/exec-7/budget.xlsx"); - 841
assert!( - 842
output - 843
.content - 844
.contains("Compared with the workspace file: Budget: 1 changed"), - 845
"{}", - 846
output.content - 847
); - 848
assert!( - 849
output - 850
.content - 851
.contains("Excel calculates them when the file is opened") - 852
); - 853
let draft = dir.path().join(".vak/scratch/mira/exec-7/budget.xlsx"); - 854
let document = vak_ooxml::read::read( - 855
std::io::Cursor::new(std::fs::read(&draft).unwrap()), - 856
vak_ooxml::Limits::default(), - 857
) - 858
.unwrap(); - 859
assert!(document.lines().join("\n").contains("B2: 150")); - 860
- 861
let mut seen = Vec::new(); - 862
while let Ok(event) = events.try_recv() { - 863
seen.push(event); - 864
} - 865
assert!(seen.iter().any(|event| matches!(event, - 866
SandboxEvent::ExecutionStarted { tool, scratch_dir, .. } - 867
if tool == "office_apply" && scratch_dir.ends_with(".vak/scratch/mira/exec-7")))); - 868
assert!(seen.iter().any(|event| matches!(event, - 869
SandboxEvent::ExecutionFinished { exit_code: 0, artifacts, .. } - 870
if artifacts == &vec![".vak/scratch/mira/exec-7/budget.xlsx".to_string()]))); - 871
- 872
let stale = run( - 873
dir.path(), - 874
serde_json::json!({ - 875
"path": "budget.xlsx", - 876
"base_digest": "0000000000000000", - 877
"ops": [{"op": "set_cells", "sheet": "Budget", "cells": {"B2": 1}}] - 878
}), - 879
) - 880
.await; - 881
assert!(stale.is_error); - 882
assert!( - 883
stale.content.contains("does not match the source"), - 884
"{}", - 885
stale.content - 886
); - 887
} - 888
- 889
#[tokio::test] - 890
async fn drafts_chain_and_a_template_creates_a_new_file() { - 891
let dir = tempfile::tempdir().unwrap(); - 892
let template = dir.path().join("brand.pptx"); - 893
std::fs::write(&template, vak_ooxml::fixtures::pptx_template()).unwrap(); - 894
let first = run( - 895
dir.path(), - 896
serde_json::json!({ - 897
"path": "q3.pptx", - 898
"source": "brand.pptx", - 899
"base_digest": digest_of(&template), - 900
"ops": [{"op": "add_slide_from_layout", "layout": "Title and Content", - 901
"placeholders": {"title": "Q3", "body": ["Revenue", "Hiring"]}}] - 902
}), - 903
) - 904
.await; - 905
assert!(!first.is_error, "{}", first.content); - 906
assert!( - 907
first.content.contains("It is a new file: 2 slides."), - 908
"{}", - 909
first.content - 910
); - 911
assert!( - 912
!dir.path().join("q3.pptx").exists(), - 913
"nothing lands in the workspace before review" - 914
); - 915
let draft = draft_path(&first); - 916
let second = run( - 917
dir.path(), - 918
serde_json::json!({ - 919
"path": "q3.pptx", - 920
"source": draft, - 921
"base_digest": digest_of(&dir.path().join(&draft)), - 922
"ops": [{"op": "set_notes", "anchor": "slide:256", "text": "x"}] - 923
}), - 924
) - 925
.await; - 926
assert!( - 927
second.is_error, - 928
"the template has no notes master to make a notes page from" - 929
); - 930
assert!( - 931
second.content.contains("no notes master"), - 932
"{}", - 933
second.content - 934
); - 935
let third = run( - 936
dir.path(), - 937
serde_json::json!({ - 938
"path": "q3.pptx", - 939
"source": draft, - 940
"base_digest": digest_of(&dir.path().join(&draft)), - 941
"ops": [{"op": "set_title", "title": "Q3 review"}] - 942
}), - 943
) - 944
.await; - 945
assert!(!third.is_error, "{}", third.content); - 946
let latest = dir.path().join(draft_path(&third)); - 947
let document = vak_ooxml::read::read( - 948
std::io::Cursor::new(std::fs::read(latest).unwrap()), - 949
vak_ooxml::Limits::default(), - 950
) - 951
.unwrap(); - 952
assert_eq!(document.title.as_deref(), Some("Q3 review")); - 953
assert!( - 954
document.lines().join("\n").contains("Slide 2: Q3"), - 955
"the chained draft keeps the first edit" - 956
); - 957
} - 958
- 959
#[tokio::test] - 960
async fn a_new_document_is_written_clean_and_an_existing_one_tracked() { - 961
let dir = tempfile::tempdir().unwrap(); - 962
let brand = dir.path().join("brand.docx"); - 963
std::fs::write(&brand, vak_ooxml::fixtures::docx()).unwrap(); - 964
let body = |output: &ToolOutput| { - 965
let bytes = std::fs::read(dir.path().join(draft_path(output))).unwrap(); - 966
let mut package = - 967
vak_ooxml::Package::open(std::io::Cursor::new(bytes), vak_ooxml::Limits::default()) - 968
.unwrap(); - 969
String::from_utf8(package.read_part("word/document.xml").unwrap()).unwrap() - 970
}; - 971
let ops = serde_json::json!([ - 972
{"op": "replace_paragraph_text", "anchor": "p@11", "text": "Growing."}, - 973
{"op": "add_paragraph", "text": "Details", "after": "p@1"} - 974
]); - 975
let created = run( - 976
dir.path(), - 977
serde_json::json!({"path": "memo.docx", "source": "brand.docx", "base_digest": digest_of(&brand), "ops": ops}), - 978
) - 979
.await; - 980
assert!(!created.is_error, "{}", created.content); - 981
assert!( - 982
!body(&created).contains(r#"w:author="mira""#), - 983
"a file not yet in the workspace is a new document, written clean" - 984
); - 985
let edited = run( - 986
dir.path(), - 987
serde_json::json!({"path": "brand.docx", "base_digest": digest_of(&brand), "ops": ops}), - 988
) - 989
.await; - 990
assert!(!edited.is_error, "{}", edited.content); - 991
assert!( - 992
body(&edited).contains(r#"w:author="mira""#), - 993
"an existing document is edited with tracked changes" - 994
); - 995
let revised = OfficeApplyTool - 996
.execute( - 997
&serde_json::json!({"path": "brand.docx", "base_digest": digest_of(&brand), "ops": ops}), - 998
&ToolContext::new(dir.path().to_path_buf()) - 999
.with_agent_id("mira") - 1000
.with_new_documents(vec!["brand.docx".into()]),
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.