diff --git a/AGENT_READINESS.md b/AGENT_READINESS.md index 4a94612f..b52ed294 100644 --- a/AGENT_READINESS.md +++ b/AGENT_READINESS.md @@ -105,7 +105,7 @@ narrower target rules. | Reclaim disk: `cache prune [--forget-user ID] [--yes] --json` | What would be (or was) removed per store, with the cost of the removed paid answers; or the refusals | Previews until `--yes`. Keeps everything a remaining run names; refuses (`shared_cache`, `job_outstanding`, `cache_in_use`, unreadable runs, interrupted older runs) rather than guess, and removes nothing then. `--forget-user` is the explicit statement that another project no longer uses the cache. CLI-only. | | Diagnose jobs: `jobs`; reconcile: `jobs --forget ` | Text job records; explicit deletion of a job record | Forgetting can permit a fresh paid submission. Use only after reconciling the provider outcome, not as generic failure recovery. | | Maintain node locks: `lock [where] --check` / `lock [where]` | Lock verification / lockfile updates | `--same` asserts unchanged behavior; it is not a way to silence a real identity change. | -| Deliver: `run ... --deliver output=path` or SDK result delivery | Copies of available declared outputs | CLI delivery happens after execution and reports missing outputs. Preserve immutable store/placed files; copy out before editing bytes. | +| Deliver: `run ... --deliver output=path` or SDK result delivery | Copies of available declared outputs | CLI delivery happens after execution and reports missing outputs. Preserve immutable store/placed files; copy out before editing bytes. The viewer's **Download all (.zip)** is a viewer-only convenience for a person (the same files under their `outputs/` names); agents use `--deliver` or `export`, so it has no command or SDK form. | Python and JavaScript SDKs expose planning, named execution/resume, result files and delivery. Each also hosts node bodies, builders and stand-ins in its language: diff --git a/bun.lock b/bun.lock index 28167c41..f85be4f5 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ }, "web/fx-react": { "name": "@grida/fx-react", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@grida/fx-web": "workspace:*", }, @@ -20,8 +20,9 @@ }, "web/fx-web": { "name": "@grida/fx-web", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { + "fflate": "0.8.3", "lucide": "1.52.0", }, }, @@ -309,6 +310,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "framer-motion": ["framer-motion@14.0.0", "", { "dependencies": { "motion-dom": "14.0.0", "motion-utils": "14.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], diff --git a/docs/guide/07-viewing.md b/docs/guide/07-viewing.md index 04e76b5e..ddff3121 100644 --- a/docs/guide/07-viewing.md +++ b/docs/guide/07-viewing.md @@ -65,6 +65,12 @@ open it. Under the title is the step's id (`state['waving'].draw#1`), the same o to ask an agent about that exact step. Select the workflow's name in the bar for the workflow's own inputs, outputs and notices, and the run's name. Esc, the background or × closes them; a second Esc closes the step list. + +In a run's details, **Download all (.zip)** saves its output files in one zip named after +the run, each under its name in the run folder's `outputs/` (`hero.png`, `looks/ada.png`). +Files the viewer cannot serve are left out, and the details say how many. A zip too large +for the browser tab fails and says so; each output's own link still saves it. To put +outputs in your project, use `run --deliver`. Identities, digests and raw records stay on the command line (`grida fx inspect runs/example --json`). diff --git a/web/fx-web/package.json b/web/fx-web/package.json index 656036fb..616e672e 100644 --- a/web/fx-web/package.json +++ b/web/fx-web/package.json @@ -7,6 +7,7 @@ "type": "module", "exports": { ".": "./src/index.ts", "./canvas.css": "./src/canvas.css" }, "dependencies": { + "fflate": "0.8.3", "lucide": "1.52.0" } } diff --git a/web/fx-web/src/index.ts b/web/fx-web/src/index.ts index 31ceabe8..e5b69ed5 100644 --- a/web/fx-web/src/index.ts +++ b/web/fx-web/src/index.ts @@ -192,3 +192,4 @@ export * from "./service"; export * from "./export"; export * from "./export-controller"; export * from "./values"; +export * from "./outputs"; diff --git a/web/fx-web/src/outputs.ts b/web/fx-web/src/outputs.ts new file mode 100644 index 00000000..5337eb20 --- /dev/null +++ b/web/fx-web/src/outputs.ts @@ -0,0 +1,171 @@ +import { Zip, ZipPassThrough } from "fflate"; + +/** One file of a run's declared outputs. */ +export interface OutputFile { + /** Its name in the run folder's `outputs/` (spec/store.md §8 "Names"), before [`placedName`] cuts long segments. */ + name: string; + digest: string; + kind: string; + size: number; + /** Where the viewer serves its bytes; null when it cannot. */ + url: string | null; +} + +/** The first suffix identity.md §4 lists for each kind, the one a run folder writes; `annotations` takes `.json`. */ +const SUFFIXES = new Map(Object.entries({ + "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif", + "text/markdown": ".md", "text/plain": ".txt", json: ".json", annotations: ".json", "text/yaml": ".yaml", "text/toml": ".toml", "text/html": ".html", + "audio/wav": ".wav", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "video/mp4": ".mp4", "video/webm": ".webm", "video/x-matroska": ".mkv", + "model/gltf-binary": ".glb", "model/gltf+json": ".gltf", "model/fbx": ".fbx", "file/zip": ".zip", +})); +/** What one file-system name holds, in UTF-8 bytes (store.md §8 "Length"). */ +const NAME_BYTES = 255; +/** A zip without ZIP64 holds at most 4 GiB, and a tab often cannot hold half that. */ +const ARCHIVE_LIMIT = 2 * 1024 ** 3; +const TOO_LARGE = "These outputs are too large for this tab to put in one zip. Save each output from its own link."; + +const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const utf8 = new TextEncoder(); + +/** A step path as one name: anything but a letter, a digit, `.`, `_` or `-` becomes `_`, outer `_` go, empty is `step`. */ +function safeName(text: string) { + return [...text].map((char) => /[\p{L}\p{N}._-]/u.test(char) ? char : "_").join("").replace(/^_+|_+$/g, "") || "step"; +} + +/** A key as a path: split at `/`, an empty, `.` or `..` segment becomes `_`, every other one a [`safeName`]. */ +function keyedPath(key: string) { + return key.split("/").map((segment) => ["", ".", ".."].includes(segment) ? "_" : safeName(segment)).join("/"); +} + +function named(label: string, kind: string) { + const suffix = SUFFIXES.get(kind) ?? ""; + return !suffix || label.endsWith(suffix) ? label : label + suffix; +} + +async function sha256(bytes: Uint8Array) { + return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (part) => part.toString(16).padStart(2, "0")).join(""); +} + +/** + * Each segment of `name` cut to one file-system name, as the run folder cuts it: a longer one keeps + * its first whole characters, then `~`, 16 hex characters of its SHA-256 and, last, its suffix. + */ +export async function placedName(file: Pick) { + const suffix = SUFFIXES.get(file.kind) ?? ""; + const segments = file.name.split("/"); + return (await Promise.all(segments.map(async (segment, index) => { + const bytes = utf8.encode(segment); + if (bytes.length <= NAME_BYTES) return segment; + const kept = index === segments.length - 1 && segment.endsWith(suffix) ? suffix : ""; + let stem = "", room = NAME_BYTES - 1 - 16 - kept.length; + for (const char of segment.slice(0, segment.length - kept.length)) { + room -= utf8.encode(char).length; + if (room < 0) break; + stem += char; + } + return `${stem}~${(await sha256(bytes)).slice(0, 16)}${kept}`; + }))).join("/"); +} + +/** A value's files in order (a list's items, a collection's in collection order), each with its key or its item's. */ +function filesOf(value: unknown, itemKey: string | null, found: { file: Record; key: string | null }[]) { + if (!record(value)) return found; + if (record(value.file)) { + const { file } = value; + if (typeof file.digest === "string" && /^[a-f0-9]{64}$/.test(file.digest) && typeof file.kind === "string" && Number.isSafeInteger(file.size)) { + found.push({ file, key: (typeof file.key === "string" && file.key) || itemKey || null }); + } + } else if (Array.isArray(value.list)) { + for (const item of value.list) filesOf(item, null, found); + } else if (Array.isArray(value.collection)) { + for (const entry of value.collection) if (Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string") filesOf(entry[1], entry[0], found); + } + return found; +} + +/** + * The files of a run's declared outputs, named as its run folder's `outputs/` names them: an output + * that is one file is ``; each file of any other is `/