Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ octosense-reference = { path = "apps/reference" }
makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19" }
octosense-appcard = { path = "apps/appcard/module" }
octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" }
octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" }
makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
Expand Down Expand Up @@ -131,12 +131,12 @@ makepad-d3 = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git",
octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "6cf2d90aa33ff4ae603f49c7557f3ecf986207f0" }
# --- App Hub's host service registry the Mail and llm services register
# into; the same revision as App Hub's app crate (a native app, above).
octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" }
octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" }
# The app policy (resolution, isolate settings, the Splash adapter) and the
# Hub client the Card runner resolves an app's policy with; the shell's
# glance tiles run cards under it (crates/shell/src/glance_card.rs).
octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" }
octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" }
octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" }
octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" }
# The app contract (ADR 0005), from crates.io by version: App Hub, the shell
# and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE
# 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships)
Expand Down Expand Up @@ -439,7 +439,7 @@ ruma = { git = "https://github.com/project-robius/ruma.git", branch = "tsp" }
# -i octos-core` still sees one octos source.
[patch.crates-io]
# Keep the contract identical to the pinned SDK for this release candidate.
octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "8347a489141c0e24cab564883db7c4592935a8b2" }
octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "80a64b4557aaba71f7c826c5dc69721c21c0f0fc" }
nix = { git = "https://github.com/octos-org/octos.git", rev = "18fcd3f16e527d2b601d7ae244f056fb711bb6b8" }

# pdfcraft's bootstrap PDF stack: its workspace patches these five
Expand Down
34 changes: 34 additions & 0 deletions apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,40 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and
shell by `apps/pdftools/tests/ui.py` on the sample PDFs its
`pdftools_fixture` example writes, see `apps/pdftools/README.md`):
`apps/pdf/host-service` and `cargo test --locked -p octosense-pdf-service`.
Besides the file methods (`info`, `text`, `render`, `merge`, `split`: the
system agent's five tools, which stay as they are), it serves PDF Tools
v2's open documents to apps' own requests, as
`apps/pdftools/design/SERVICE.md` specifies; change that contract and the
service together. `pdf.open` keeps the engine's document, its edits and
undo history, under a handle; a document belongs to the caller app, its
storage scope (the area's root) and the handle, so another app's or
scope's handle is `unknown_doc:`, and a caller keeps at most 8 open
(`src/docs.rs`). The table is a `thread_local`, because every call reaches
the service on the UI thread (App Hub's `services::pump` inline from the
card runner, the relay's and components' calls from `host_tools::pump`),
which must take no lock another thread can hold. `pdf.close` releases a
document, and so does its app closing: a document is bound to the opening
isolate, and the service's listener on App Hub's
`services::on_isolate_closed` (called from `cancel_heap`, which every host
calls for a closing isolate) releases its documents and their renders
before the isolate's heap key, an address, can be reused. Every call also
sweeps for a changed storage scope (Makepad's
`splash_storage::storage_for_heap` for `Replier::isolate_key()`), and a
caller with no isolate gets a 15-minute idle bound. Each method runs
reviewed engine commands, its
review beside it (`src/reading.rs`, `src/review.rs`, `src/change.rs`): the
service builds every engine argument itself and each v2 method refuses a
key it does not take, so `comment_add` (`file`) never sees its attachment
type or a path; `file` commands that write get a staging path inside the
storage only; `doc_open` and `form_fill` (`code`) run with JavaScript off,
proven by `doc_tests::a_forms_own_scripts_never_run`. Caps: 24–300 dpi and
16 MP a render, `find` ≤ 500 matches, 512 pages a call, an image export
≤ 64 pages and 256 MP in all (checked before anything is written), and a
render cache in `.cache/pages/` of 16 MiB and 64 files (an app's storage
holds at most 256 entries for its own writes), oldest first, cleared
before a write would fail with `storage_full:`. `pdf.fields` marks
read-only fields, and `pdf.fill` refuses one. Errors start with a stable
code (`src/codes.rs`).
- Declare an app's agent in its manifest and `bundle/tools.json`. Keep the
input/output schemas consistent with the executor (octos requires an object
output schema), and select the actual risk, sharing and confirmation policy.
Expand Down
18 changes: 12 additions & 6 deletions apps/pdf/host-service/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# octosense-pdf-service — the `pdf` host service (ADR 0013).
#
# pdfcraft's PDF engine behind typed `pdf.*` methods, through its own
# headless automation layer: inspect a document, extract its text, render
# a page to PNG, merge files and split one, with every path confined to
# the call's area: the caller's own folder from the shell's resolver, else
# the legacy `pdf/` corner of the caller's host directory.
# Stateless: every call opens, acts and closes.
# headless automation layer, with every path confined to the call's area:
# the caller's own folder from the shell's resolver, else the legacy `pdf/`
# corner of the caller's host directory. A call that names a file opens,
# acts and closes (inspect, extract text, render a page, merge, split);
# PDF Tools v2's open documents keep the engine's document, its edits and
# undo history between calls (apps/pdftools/design/SERVICE.md).
[package]
name = "octosense-pdf-service"
version = "0.1.0"
Expand All @@ -14,7 +15,9 @@ license = "Apache-2.0"
publish = false

[dependencies]
# The same App Hub rev as Mail and News: one appstore, one service registry.
# The same App Hub rev as Mail and News: one appstore, one service registry
# (and, through it, Makepad's per-isolate storage scope an open document is
# bound to).
octosense-appstore = { workspace = true }
# Where a call works: the caller's area and its write rules (ADR 0013).
octosense-engine-area = { workspace = true }
Expand All @@ -30,4 +33,7 @@ octosense-skill-gen = { workspace = true }
octosense-app-policy = { workspace = true }
# The engine's own scripted XFA form: proof that a document's scripts stay off.
pdfcraft-xfa = { workspace = true }
# A real app isolate with a storage scope, whose closing releases the
# documents it opened (src/doc_tests.rs).
makepad-widgets = { workspace = true }
tempfile = "3"
7 changes: 4 additions & 3 deletions apps/pdf/host-service/examples/pdftools_fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ fn main() {
}
}

/// The samples: file name and bytes.
fn documents() -> Vec<(&'static str, Vec<u8>)> {
/// The samples: file name and bytes. (The service's own tests include
/// this file as a module and use them too.)
pub(crate) fn documents() -> Vec<(&'static str, Vec<u8>)> {
vec![
("Quarterly report.pdf", quarterly_report()),
("Board minutes.pdf", board_minutes()),
Expand Down Expand Up @@ -513,7 +514,7 @@ fn apartment_lease() -> Vec<u8> {
pdf(&info(title, "Harbour Lane Homes", "Lease for 14 Harbour Lane, flat 3B", "lease, rental, agreement"), pages)
}

fn garden_plan() -> Vec<u8> {
pub(crate) fn garden_plan() -> Vec<u8> {
let title = "Garden plan - spring 2027";
let beds = [
("Bed 1: salad", 0x6a994e, "Lettuce, rocket and radishes, sown every three weeks from March for a steady supply."),
Expand Down
146 changes: 146 additions & 0 deletions apps/pdf/host-service/src/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Reading a call's arguments. Every helper refuses with `invalid:` (a cap
//! with `too_large:`) and names the key. Integers may arrive as whole
//! floats (`3.0`): a script's numbers are doubles.

use serde_json::Value as Json;

use crate::codes::{fail, invalid, Code};

/// The most pages one call names (SERVICE.md: "at most 512 pages named in
/// one call").
pub(crate) const MAX_PAGES: usize = 512;
/// The most rectangles one comment marks.
pub(crate) const MAX_RECTS: usize = 256;

/// Refuse any argument `method` does not take. The engine command behind
/// a method only ever gets the parameters reviewed for it, which the
/// service builds itself; a key it was not reviewed for (a file `path` on
/// a comment) is refused here, before any engine work. A key set to `null`
/// counts as absent.
pub(crate) fn only(args: &Json, keys: &[&str], method: &str) -> Result<(), String> {
match args {
Json::Null => Ok(()),
Json::Object(map) => match map.iter().find(|(k, v)| !v.is_null() && !keys.contains(&k.as_str())) {
Some((k, _)) => Err(invalid(format!("{method} takes {}; `{k}` is not one of them", keys.join(", ")))),
None => Ok(()),
},
_ => Err(invalid(format!("{method}'s arguments are an object"))),
}
}

fn present<'a>(args: &'a Json, key: &str) -> Option<&'a Json> {
args.get(key).filter(|v| !v.is_null())
}

/// A required, non-empty string.
pub(crate) fn need_str<'a>(args: &'a Json, key: &str, m: &str) -> Result<&'a str, String> {
present(args, key).and_then(Json::as_str).filter(|s| !s.is_empty()).ok_or_else(|| invalid(format!("{m} needs `{key}`, a string")))
}

/// An optional string of at most `max` characters.
pub(crate) fn opt_str<'a>(args: &'a Json, key: &str, m: &str, max: usize) -> Result<Option<&'a str>, String> {
let Some(v) = present(args, key) else { return Ok(None) };
let s = v.as_str().ok_or_else(|| invalid(format!("{m}: `{key}` is a string")))?;
if s.chars().count() > max {
return Err(fail(Code::TooLarge, format!("{m}: `{key}` is at most {max} characters")));
}
Ok(Some(s))
}

/// A whole number, from an integer or a whole float.
fn whole(v: &Json) -> Option<i64> {
v.as_i64().or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && f.abs() < 9.0e15).map(|f| f as i64))
}

/// An optional whole number.
pub(crate) fn opt_int(args: &Json, key: &str, m: &str) -> Result<Option<i64>, String> {
let Some(v) = present(args, key) else { return Ok(None) };
whole(v).map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is a whole number")))
}

/// A required whole number ≥ 1 (a 1-based page, a position).
pub(crate) fn positive(args: &Json, key: &str, m: &str) -> Result<u64, String> {
match opt_int(args, key, m)? {
Some(n) if n >= 1 => Ok(n as u64),
_ => Err(invalid(format!("{m} needs `{key}`, a whole number from 1"))),
}
}

pub(crate) fn opt_bool(args: &Json, key: &str, m: &str) -> Result<Option<bool>, String> {
let Some(v) = present(args, key) else { return Ok(None) };
v.as_bool().map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is true or false")))
}

/// 1-based page numbers: 1 to [`MAX_PAGES`] of them.
pub(crate) fn opt_pages(args: &Json, key: &str, m: &str) -> Result<Option<Vec<u64>>, String> {
let Some(v) = present(args, key) else { return Ok(None) };
let list = v.as_array().ok_or_else(|| invalid(format!("{m}: `{key}` is a list of 1-based page numbers")))?;
if list.is_empty() {
return Err(invalid(format!("{m}: `{key}` names at least one page")));
}
if list.len() > MAX_PAGES {
return Err(fail(Code::TooLarge, format!("{m}: `{key}` names at most {MAX_PAGES} pages in one call")));
}
list.iter()
.map(|p| whole(p).filter(|n| *n >= 1).map(|n| n as u64).ok_or_else(|| invalid(format!("{m}: `{key}` holds 1-based page numbers"))))
.collect::<Result<Vec<_>, _>>()
.map(Some)
}

pub(crate) fn pages(args: &Json, key: &str, m: &str) -> Result<Vec<u64>, String> {
opt_pages(args, key, m)?.ok_or_else(|| invalid(format!("{m} needs `{key}`, a list of 1-based page numbers")))
}

fn finite(v: &Json) -> Option<f64> {
v.as_f64().filter(|f| f.is_finite() && f.abs() <= 1.0e6)
}

/// A point `[x, y]` in points from the top-left of the displayed page.
pub(crate) fn opt_point(args: &Json, key: &str, m: &str) -> Result<Option<[f64; 2]>, String> {
let Some(v) = present(args, key) else { return Ok(None) };
match v.as_array().map(|a| a.iter().map(finite).collect::<Option<Vec<f64>>>()) {
Some(Some(p)) if p.len() == 2 => Ok(Some([p[0], p[1]])),
_ => Err(invalid(format!("{m}: `{key}` is [x, y] in points"))),
}
}

/// Rectangles `[[x, y, w, h], …]` in points from the top-left of the
/// displayed page, each with a positive size: 1 to [`MAX_RECTS`] of them.
pub(crate) fn opt_rects(args: &Json, key: &str, m: &str) -> Result<Option<Vec<[f64; 4]>>, String> {
let Some(v) = present(args, key) else { return Ok(None) };
let wrong = || invalid(format!("{m}: `{key}` is a list of [x, y, w, h] in points, each with a positive width and height"));
let list = v.as_array().filter(|l| !l.is_empty()).ok_or_else(wrong)?;
if list.len() > MAX_RECTS {
return Err(fail(Code::TooLarge, format!("{m}: `{key}` holds at most {MAX_RECTS} rectangles")));
}
list.iter()
.map(|r| match r.as_array().map(|a| a.iter().map(finite).collect::<Option<Vec<f64>>>()) {
Some(Some(r)) if r.len() == 4 && r[2] > 0.0 && r[3] > 0.0 => Ok([r[0], r[1], r[2], r[3]]),
_ => Err(wrong()),
})
.collect::<Result<Vec<_>, _>>()
.map(Some)
}

/// A resolution in dots per inch, `lo..=hi`, rounded to a whole number.
pub(crate) fn dpi(args: &Json, key: &str, m: &str, default: u32, (lo, hi): (u32, u32)) -> Result<u32, String> {
let Some(v) = present(args, key) else { return Ok(default) };
let d = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| invalid(format!("{m}: `{key}` is a number")))?.round();
if d < f64::from(lo) || d > f64::from(hi) {
return Err(invalid(format!("{m}: `{key}` is {lo} to {hi}")));
}
Ok(d as u32)
}

/// Engine geometry `[x0, y0, x1, y1]` (top-left origin) as SERVICE.md's
/// `[x, y, w, h]`, to 1/100 pt; `null` when it is not four numbers.
pub(crate) fn xywh(r: &Json) -> Json {
let n: Option<Vec<f64>> = r.as_array().map(|a| a.iter().filter_map(Json::as_f64).collect());
match n {
Some(v) if v.len() == 4 => {
let round = |x: f64| (x * 100.0).round() / 100.0;
serde_json::json!([round(v[0].min(v[2])), round(v[1].min(v[3])), round((v[2] - v[0]).abs()), round((v[3] - v[1]).abs())])
}
_ => Json::Null,
}
}
Loading
Loading