Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 5 additions & 2 deletions cell/entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,13 @@ class ContainerHost {
// and BACKUP_INTEGRITY); `{full}` when it does not fit on this disk (the
// shim stops at its floor, ENOSPC, and removes what it wrote). The Rust
// side tells both from a failure that may pass (it throws).
async restore(generation, record, ms) {
// `dir`, when named, is where it goes instead of its own directory (a
// folder set aside, coming back beside the live one).
async restore(generation, record, ms, dir) {
if (generation !== this.#generation) throw new Error(`start ${generation} is not the running one`);
try {
await bounded(`the restore of ${record.dir}`, ms, (signal) => this.#backups.restore(record, { signal }));
const options = dir ? { dir } : {};
await bounded(`the restore of ${dir || record.dir}`, ms, (signal) => this.#backups.restore(record, { ...options, signal }));
return null;
} catch (e) {
if (SandboxBackupError.is(e) && (e.code === "BACKUP_NOT_FOUND" || e.code === "BACKUP_INTEGRITY")) {
Expand Down
10 changes: 10 additions & 0 deletions cell/shell-tests/computer-aside.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ test("each folder set aside is told, oldest first; small ones in MB, an unknown
`Your folder from ${day} (2.5 GB) didn't fit, so your computer started without it. It's kept safe.`,
);
});

test("its two ways out: one folder's buttons say it, several name their folders", async () => {
const { asideActionWords, asideDropAsk } = await import("../shell/computer.js");
const work = { record: "r1", dir: "/data/work", bytes: 5_887_397_516, save: 594, savedAt: at, at };
const files = { record: "r2", dir: "/data", bytes: 420_000_000, save: 590, savedAt: at, at };
assert.deepEqual(asideActionWords(work, false), ["Get it back", "Delete it"]);
assert.deepEqual(asideActionWords(work, true), [`Get back the work folder from ${day}`, `Delete the work folder from ${day}`]);
assert.deepEqual(asideActionWords(files, true), [`Get back the computer's own files from ${day}`, `Delete the computer's own files from ${day}`]);
assert.equal(asideDropAsk(work), `Delete your work folder from ${day} (5.9 GB)? It can't be brought back after.`);
});
58 changes: 56 additions & 2 deletions cell/shell/computer.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ export const PHASE = { asleep: "Asleep", starting: "Starting", awake: "Awake", s
// oldest first, with the day it was saved and its saved size.
export const ASIDE_TITLE = "A folder didn't fit on your computer";
const ASIDE_FOLDER = { "/data/work": ["work folder", "it", "It's"], "/data": ["computer's own files", "them", "They're"] };
const size = (b) => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : `${Math.max(1, Math.round(b / 1e6))} MB`);
const day = (ms) => new Date(ms).toLocaleDateString([], { month: "short", day: "numeric" });
// Its two ways out: back beside the live work (nothing there is replaced),
// or deleted. With several aside, each button names its folder.
export function asideActionWords(a, several) {
const [folder] = ASIDE_FOLDER[a.dir] ?? ["folder"];
return several ? [`Get back the ${folder} from ${day(a.savedAt)}`, `Delete the ${folder} from ${day(a.savedAt)}`] : ["Get it back", "Delete it"];
}
export function asideDropAsk(a) {
const [folder] = ASIDE_FOLDER[a.dir] ?? ["folder"];
return `Delete your ${folder} from ${day(a.savedAt)} (${size(a.bytes)})? It can't be brought back after.`;
}
export function setAsideSaid(aside) {
const size = (b) => (b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : `${Math.max(1, Math.round(b / 1e6))} MB`);
const day = (ms) => new Date(ms).toLocaleDateString([], { month: "short", day: "numeric" });
return aside
.map((a) => {
const [folder, it, kept] = ASIDE_FOLDER[a.dir] ?? ["folder", "it", "It's"];
Expand Down Expand Up @@ -133,6 +143,20 @@ export function createComputer({ api, el, seg, named, section, line, actionLine,
actions.append(later);
} else if (n.kind === "set_aside") {
say(ASIDE_TITLE, setAsideSaid(n.aside));
for (const a of n.aside) {
const [backWords, dropWords] = asideActionWords(a, n.aside.length > 1);
const back = el("button", "primary", backWords);
back.type = "button";
back.dataset.action = "aside-back";
back.dataset.record = a.record;
back.onclick = () => asideBack(a, back);
const drop = el("button", null, dropWords);
drop.type = "button";
drop.dataset.action = "aside-drop";
drop.dataset.record = a.record;
drop.onclick = () => asideDrop(a, drop);
actions.append(back, drop);
}
} else if (n.kind === "deploying") {
copy.append(el("strong", null, "Updating — back in a moment"));
} else if (n.kind === "refresh") {
Expand Down Expand Up @@ -198,6 +222,36 @@ export function createComputer({ api, el, seg, named, section, line, actionLine,
computerIs(await api("GET", `/api/computers/${seg(c.computer)}`).catch(() => null));
}
}
// A folder set aside, back beside the live work (it wakes the computer
// if it sleeps; a big one takes minutes), or deleted once they confirm.
// What did not work says why under its button.
async function asideBack(a, button) {
const c = computer();
if (!c) return;
const label = button.textContent;
button.disabled = true;
button.textContent = "Getting it back…";
try {
const r = await api("POST", `/api/computers/${seg(c.computer)}/aside/${seg(a.record)}/back`, {});
computerIs(r.view);
} catch (e) {
button.disabled = false;
button.textContent = label;
const said = button.parentElement.querySelector(".form-error") ?? el("p", "form-error");
said.textContent = e.message;
button.parentElement.append(said);
}
}
async function asideDrop(a, button) {
const c = computer();
if (!c || !confirm(asideDropAsk(a))) return;
button.disabled = true;
try {
computerIs(await api("DELETE", `/api/computers/${seg(c.computer)}/aside/${seg(a.record)}`));
} catch {
computerIs(await api("GET", `/api/computers/${seg(c.computer)}`).catch(() => null));
}
}
// Later: this update is told no more, anywhere, until another is ready.
async function updateLater(version, button) {
const c = computer();
Expand Down
97 changes: 96 additions & 1 deletion cell/src/computer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use std::collections::BTreeMap;

use fragment_core::catalog::{self, Kind};
use fragment_core::computer::{
alive, Aside, does_simplex, image_running, notices, valid_version, version_running, Action, Alive, Event, Images, Lifecycle, Phase, Plan as Restore, Rules, Save, SaveNote, Saves, Saving, Socket, Step, Wake, ALIVE_MS, ASIDE_MAX, HOLD_DEADLINE_MS, HOLD_WAIT_MS, SAVE_DEADLINE_MS,
alive, back_dir, Aside, does_simplex, image_running, notices, valid_version, version_running, Action, Alive, Event, Images, Lifecycle, Phase, Plan as Restore, Rules, Save, SaveNote, Saves, Saving, Socket, Step, Wake, ALIVE_MS, ASIDE_MAX, HOLD_DEADLINE_MS, HOLD_WAIT_MS, SAVE_DEADLINE_MS,
SLEEP_DEADLINE_MS, START_DEADLINE_MS,
};
use fragment_core::ledger::{Meter, MeterRow, Month, Spend};
Expand Down Expand Up @@ -384,6 +384,8 @@ pub struct ComputerCell {
/// end is that start's own failure, which the start reports itself
/// (`exit_of`), never a crash as well.
failing: std::cell::Cell<Option<u64>>,
/// A folder set aside is coming back now (`bring_back`): one at a time.
bringing: std::cell::Cell<bool>,
/// Its XFTP relays open now (`fragment_core::relay::Leases`). In memory
/// only: a restart forgets them, and they lapse on their own bounds.
relays: RefCell<fragment_core::relay::Leases>,
Expand Down Expand Up @@ -411,6 +413,7 @@ impl DurableObject for ComputerCell {
wiped: std::cell::Cell::new(wiped),
heard_ms: std::cell::Cell::new(None),
failing: std::cell::Cell::new(None),
bringing: std::cell::Cell::new(false),
relays: RefCell::default(),
schema,
}
Expand Down Expand Up @@ -695,6 +698,14 @@ struct TestLever {
dir: Option<String>,
}

/// A folder set aside, named by its record's id (`computer/aside-back`,
/// `computer/aside-drop`).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AsideAsk {
record: String,
}

#[derive(Deserialize)]
struct Tab {
open: bool,
Expand Down Expand Up @@ -1316,6 +1327,69 @@ impl ComputerCell {
Ok(())
}

/// Its owner gets back the folder set aside as `record`: awake (woken as
/// its owner's wake, if it sleeps), it is restored beside the live work
/// (`back_dir`), so nothing live is replaced; then it leaves what is
/// aside and its archive is deleted, its files in `/data` now, which its
/// next save keeps. Answers where it came back. One that does not fit
/// yet (the floor stopped it, writing nothing) or whose archive will
/// never restore stays aside, and says so (409); none aside by that id
/// is 404. One at a time.
async fn bring_back(&self, record: &str) -> CellResult<String> {
let folder = self.aside()?.get(record).cloned().ok_or_else(|| CellError::new(ErrorCode::NotFound, format!("no folder is set aside as {record}")))?;
if self.bringing.replace(true) {
return Err(CellError::new(ErrorCode::Conflict, "a folder set aside is coming back already: wait for it"));
}
let done = self.try_bring_back(record, &folder).await;
self.bringing.set(false);
done
}

async fn try_bring_back(&self, record: &str, folder: &fragment_core::computer::AsideFolder) -> CellResult<String> {
if !matches!(self.lifecycle()?.phase, Phase::Awake { .. }) {
self.wake(Wake::Owner).await?;
}
let life = self.lifecycle()?;
let Phase::Awake { generation, .. } = life.phase else {
return Err(CellError::new(ErrorCode::Conflict, "it is not awake to take it: try again once it is"));
};
let g = JsValue::from_f64(generation as f64);
let back = back_dir(folder);
let id = self.meta(MetaKey::Id)?;
self.mark(&g, &format!("mkdir -p {}", SAVED_DIRS[1]), MARK_EXEC_MS).await?;
let t0 = js::now_ms();
let answer = js::from_js(&self.call("restore", &[g, js::to_js(&folder.record), JsValue::from_f64(RESTORE_MS as f64), back.as_str().into()]).await?).map_err(CellError::host)?;
if let Some(why) = answer["full"].as_str().or(answer["unusable"].as_str()) {
console_log!("{}", json!({ "computer": id, "aside": record, "back": "refused", "why": why }));
let said = if answer["full"].is_string() { "it does not fit yet: free some room on the computer, and try again" } else { "its archive will never restore: it can only be dropped" };
return Err(CellError::new(ErrorCode::Conflict, format!("{said} ({why})")));
}
// in /data now: it leaves what is aside, its archive let go of, in one step
let (mut saves, mut aside) = (self.saves()?, self.aside()?);
let lost = aside.release(record).map(|f| saves.let_go(vec![f.record])).unwrap_or_default();
self.set_meta(MetaKey::Aside, &serde_json::to_string(&aside).map_err(|e| CellError::host(e.to_string()))?)?;
self.set_meta(MetaKey::Saves, &serde_json::to_string(&saves).map_err(|e| CellError::host(e.to_string()))?)?;
console_log!("{}", json!({ "computer": id, "aside": record, "back": back, "save": folder.save, "bytes": folder.record["size"], "ms": js::now_ms() - t0 }));
self.forget(lost).await;
self.tell_owner().await;
Ok(back)
}

/// Its owner drops the folder set aside as `record`: it leaves, and its
/// archive is deleted, as a save pushed out is (`Saves::let_go`), in the
/// same step. None aside by that id: 404.
async fn drop_aside(&self, record: &str) -> CellResult<()> {
let (mut saves, mut aside) = (self.saves()?, self.aside()?);
let folder = aside.release(record).ok_or_else(|| CellError::new(ErrorCode::NotFound, format!("no folder is set aside as {record}")))?;
let lost = saves.let_go(vec![folder.record.clone()]);
self.set_meta(MetaKey::Aside, &serde_json::to_string(&aside).map_err(|e| CellError::host(e.to_string()))?)?;
self.set_meta(MetaKey::Saves, &serde_json::to_string(&saves).map_err(|e| CellError::host(e.to_string()))?)?;
console_log!("{}", json!({ "computer": self.meta(MetaKey::Id)?, "aside": record, "dropped": folder.save, "bytes": folder.record["size"] }));
self.forget(lost).await;
self.tell_owner().await;
Ok(())
}

/// A folder of the save a start restores did not fit on its disk (the
/// SDK's floor stopped its restore, which removed what it wrote): it is
/// set aside (`Saves::set_aside`), kept and told, and the start goes on
Expand Down Expand Up @@ -2334,6 +2408,18 @@ impl ComputerCell {
self.drive(Event::Sleep).await?;
json_response(&self.view()?)
}
"computer/aside-back" => {
let b: AsideAsk = body_json(&mut req).await?;
self.must(MetaKey::Id)?;
let back = self.bring_back(&b.record).await?;
json_response(&json!({ "back": back, "view": self.view()? }))
}
"computer/aside-drop" => {
let b: AsideAsk = body_json(&mut req).await?;
self.must(MetaKey::Id)?;
self.drop_aside(&b.record).await?;
json_response(&self.view()?)
}
"computer/restart" => {
let b: RestartAsk = body_json(&mut req).await?;
self.must(MetaKey::Id)?;
Expand Down Expand Up @@ -2955,6 +3041,15 @@ pub(crate) async fn route(env: &Env, who: &str, kind: IdentityKind, method: Meth
owned(env, who, id).await?;
json_response(&view_of(ask(env, id, "computer/sleep", &json!({})).await?)?)
}
(Method::Post, [id, "aside", record, "back"]) => {
owned(env, who, id).await?;
let answer = ask(env, id, "computer/aside-back", &json!({ "record": record })).await?;
json_response(&json!({ "back": answer["back"], "view": view_of(answer["view"].clone())? }))
}
(Method::Delete, [id, "aside", record]) => {
owned(env, who, id).await?;
json_response(&view_of(ask(env, id, "computer/aside-drop", &json!({ "record": record })).await?)?)
}
(Method::Post, [id, "restart"]) => {
owned(env, who, id).await?;
// no body restarts whatever runs; a start named is restarted once
Expand Down
49 changes: 49 additions & 0 deletions crates/core/src/computer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1948,6 +1948,30 @@ impl Aside {
pub fn records(&self) -> Vec<Value> {
self.folders.iter().map(|a| a.record.clone()).collect()
}

/// The folder set aside as `record`, if one is.
pub fn get(&self, record: &str) -> Option<&AsideFolder> {
self.folders.iter().find(|a| a.record["id"] == record)
}

/// The folder set aside as `record` leaves: its owner dropped it, or it
/// came back into `/data` (`back_dir`). Answered for the DO to let its
/// record go (`Saves::let_go`, which deletes its archive), in the same
/// step it writes this. None when none is aside by that id: again, none.
pub fn release(&mut self, record: &str) -> Option<AsideFolder> {
let at = self.folders.iter().position(|a| a.record["id"] == record)?;
Some(self.folders.remove(at))
}
}

/// Where a folder set aside comes back when its owner asks: a folder of its
/// own beside the live work, named for what it was (`work`, or the
/// computer's own `files`), the day it was saved and its save's number, so
/// nothing the computer did since is replaced and two never meet.
pub fn back_dir(folder: &AsideFolder) -> String {
let (y, m, d) = crate::cron::civil(folder.saved_at_ms.div_euclid(1000).div_euclid(86_400));
let what = if folder.record["dir"] == "/data" { "files" } else { "work" };
format!("/data/work/restored-{what}-{y:04}-{m:02}-{d:02}-{}", folder.save)
}

/// One folder set aside (`Aside`).
Expand Down Expand Up @@ -2817,6 +2841,31 @@ mod tests {
assert!(serde_json::from_str::<Aside>("{}").unwrap().folders().is_empty());
}

/// Goal: a folder set aside leaves when its owner drops it or it comes
/// back, and its record is answered to be let go of (its archive
/// deleted) once; it comes back beside the live work, in a folder named
/// for what it was, its save's day and number, so two never meet and
/// nothing live is replaced. Valid, replay (released again: none),
/// invalid (an id never aside). Method: the folders as the DO keeps them.
#[test]
fn a_folder_aside_comes_back_beside_the_work_or_is_dropped() {
let two = |id: &str| vec![json!({ "id": id, "dir": "/data", "size": 10 }), json!({ "id": format!("{id}w"), "dir": "/data/work", "size": 20 })];
let (mut s, mut a) = (Saves::default(), Aside::default());
let nov3 = 1_793_716_000_000; // 2026-11-03, UTC
s.saved(1, two("b1"), nov3, true);
s.saved(2, two("b2"), nov3 + 60_000, true);
assert!(a.set_aside(&mut s, "b1w", nov3 + 1) && a.set_aside(&mut s, "b2", nov3 + 2));
assert_eq!(back_dir(a.get("b1w").unwrap()), "/data/work/restored-work-2026-11-03-1");
assert_eq!(back_dir(a.get("b2").unwrap()), "/data/work/restored-files-2026-11-03-2", "its own files, another save: another folder");
let gone = a.release("b1w").expect("it was aside");
assert_eq!(gone.record["id"], "b1w");
assert!(a.release("b1w").is_none() && a.get("b1w").is_none(), "released once");
assert!(a.release("nope").is_none());
assert_eq!(a.folders().len(), 1);
let lost = s.let_go(vec![gone.record]);
assert!(lost.is_empty() && s.forgetting().iter().any(|r| r["id"] == "b1w"), "its archive is deleted as any let go of");
}

/// Goal (P3, decision 19): a snapshot is of the image its start ran, so
/// an image pinned while it runs is taken at the next wake, from the
/// save, never answered with a snapshot of the old image. On master the
Expand Down
Loading
Loading