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
2 changes: 1 addition & 1 deletion AGENT_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` | 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:
Expand Down
7 changes: 5 additions & 2 deletions bun.lock

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

6 changes: 6 additions & 0 deletions docs/guide/07-viewing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
1 change: 1 addition & 0 deletions web/fx-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"type": "module",
"exports": { ".": "./src/index.ts", "./canvas.css": "./src/canvas.css" },
"dependencies": {
"fflate": "0.8.3",
"lucide": "1.52.0"
}
}
1 change: 1 addition & 0 deletions web/fx-web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,4 @@ export * from "./service";
export * from "./export";
export * from "./export-controller";
export * from "./values";
export * from "./outputs";
171 changes: 171 additions & 0 deletions web/fx-web/src/outputs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => 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<ArrayBuffer>) {
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<OutputFile, "name" | "kind">) {
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<string, unknown>; 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 `<name><suffix>`; each file of any other is `<name>/<label><suffix>`, its label
* its key as a path, else its position among the output's files. `locate` gives a file's URL.
*/
export function outputFiles(outputs: Record<string, unknown>, locate: (digest: string) => string | null): OutputFile[] {
return Object.entries(outputs).flatMap(([output, value]) => filesOf(value, null, []).map(({ file, key }, index) => ({
name: named(record(value) && record(value.file) ? output : `${output}/${key === null ? index : keyedPath(key)}`, file.kind as string),
digest: file.digest as string,
kind: file.kind as string,
size: file.size as number,
url: locate(file.digest as string),
})));
}

export interface ArchiveOptions { fetcher?: typeof fetch; signal?: AbortSignal; limit?: number }

/**
* The files that have a URL in one uncompressed zip (media is compressed already), each checked
* against its recorded size and digest. A later file at a name replaces an earlier one, as in the
* run folder. Refuses, before reading anything, files that come to more than `limit` bytes.
*/
export async function outputArchive(files: OutputFile[], { fetcher = fetch, signal, limit = ARCHIVE_LIMIT }: ArchiveOptions = {}): Promise<Blob> {
const entries = new Map<string, OutputFile>();
for (const file of files) if (file.url !== null) { const name = await placedName(file); entries.delete(name); entries.set(name, file); }
const total = [...entries.values()].reduce((sum, file) => sum + file.size, 0);
if (total > limit) throw new Error(`These outputs come to ${(total / 1024 ** 3).toFixed(1)} GB, more than this tab can put in one zip. Save each output from its own link.`);
const chunks: Uint8Array<ArrayBuffer>[] = [];
const failures: Error[] = [];
const zip = new Zip((error, chunk) => { if (error) failures.push(error); else chunks.push(chunk); });
for (const [name, file] of entries) {
const response = await fetcher(file.url!, { signal });
if (!response.ok) throw new Error(`${file.name} could not be read (HTTP ${response.status}).`);
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.length !== file.size || await sha256(bytes) !== file.digest) throw new Error(`${file.name} is not the file the run recorded.`);
const entry = new ZipPassThrough(name);
zip.add(entry);
entry.push(bytes, true);
}
zip.end();
if (failures.length) throw failures[0];
return new Blob(chunks, { type: "application/zip" });
}

export interface OutputArchiveState { building: boolean; error: string | null }

/** Puts a run's output files in one zip and saves it; the viewer only mounts it. */
export class OutputArchiveController {
private state: OutputArchiveState = { building: false, error: null };
private listeners = new Set<() => void>();
private request: AbortController | null = null;

constructor(private readonly options: Omit<ArchiveOptions, "signal"> & { save?: (archive: Blob, name: string) => void } = {}) {}

getSnapshot = () => this.state;
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
private update(next: OutputArchiveState) { this.state = next; this.listeners.forEach((listener) => listener()); }

/** Saves `files` as `<name>.zip`. */
download = async (files: OutputFile[], name: string) => {
this.request?.abort();
const request = new AbortController();
this.request = request;
this.update({ building: true, error: null });
try {
const archive = await outputArchive(files, { ...this.options, signal: request.signal });
if (request.signal.aborted) return;
(this.options.save ?? save)(archive, `${name}.zip`);
this.update({ building: false, error: null });
} catch (error) {
if (request.signal.aborted) return;
this.update({ building: false, error: error instanceof RangeError ? TOO_LARGE : error instanceof Error ? error.message : "The outputs could not be zipped." });
}
};

dispose() { this.request?.abort(); }
}

function save(archive: Blob, name: string) {
const link = Object.assign(document.createElement("a"), { href: URL.createObjectURL(archive), download: name });
document.body.append(link);
link.click();
link.remove();
// Revoked later: some browsers still read the URL after the click returns.
setTimeout(() => URL.revokeObjectURL(link.href), 60_000);
}
95 changes: 95 additions & 0 deletions web/fx-web/tests/outputs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import { unzipSync } from "fflate";
import { OutputArchiveController, outputArchive, outputFiles, placedName, type OutputFile } from "../src/index";

const bytes = (text: string) => new TextEncoder().encode(text);
const hash = (data: Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
const contents = new Map<string, Uint8Array>();
/** A recorded file value holding `text`. */
function file(text: string, kind = "image/png", key?: string) {
const data = bytes(text);
contents.set(hash(data), data);
return { file: { digest: hash(data), kind, name: "ignored.bin", size: data.length, ...(key ? { key } : {}) } };
}
const locate = (digest: string) => `https://assets.example.com/artifacts/${digest}`;
const fetcher = (async (url: string) => {
const data = contents.get(url.split("/").pop()!);
return data ? new Response(data) : new Response(null, { status: 404 });
}) as unknown as typeof fetch;
async function unzip(files: OutputFile[]) {
return Object.fromEntries(Object.entries(unzipSync(new Uint8Array(await (await outputArchive(files, { fetcher })).arrayBuffer()))).map(([name, data]) => [name, new TextDecoder().decode(data)]));
}

describe("run output archive", () => {
test("names each file as the run folder's outputs/ does", () => {
const files = outputFiles({
hero: file("hero"),
notes: file("{}", "json"),
raw: file("raw", "file"),
sheet: { list: [file("a"), file("b", "image/jpeg")] },
looks: { collection: [["ada", file("ada")], ["hero.png", file("again")], ["a b/../c", file("deep")], ["", file("blank")]] },
own: { list: [file("own", "image/png", "keyed")] },
text: { value: "not a file" },
}, locate);
expect(files.map((item) => item.name)).toEqual([
"hero.png", "notes.json", "raw",
"sheet/0.png", "sheet/1.jpg",
"looks/ada.png", "looks/hero.png", "looks/a_b/_/c.png", "looks/3.png",
"own/keyed.png",
]);
expect(files[0]).toEqual({ name: "hero.png", digest: hash(bytes("hero")), kind: "image/png", size: 4, url: locate(hash(bytes("hero"))) });
});

test("a single file in a list or a collection still gets a folder", () => {
expect(outputFiles({ one: { list: [file("x")] }, keyed: { collection: [["k", file("x")]] } }, locate).map((item) => item.name)).toEqual(["one/0.png", "keyed/k.png"]);
});

test("cuts a long segment as the run folder does", async () => {
const long = "k".repeat(300);
expect(await placedName({ name: `looks/${long}.png`, kind: "image/png" })).toBe(`looks/${"k".repeat(234)}~8830571039dc4b0d.png`);
expect(await placedName({ name: `${"é".repeat(200)}/a.png`, kind: "image/png" })).toBe(`${"é".repeat(119)}~df20b2aa6262e99e/a.png`);
});

test("zips the available files uncompressed, leaving out those with no URL", async () => {
const files = outputFiles({ hero: file("hero"), looks: { collection: [["ada", file("ada")], ["bo", file("bo")]] } }, (digest) => digest === hash(bytes("bo")) ? null : locate(digest));
expect(files.filter((item) => item.url === null).map((item) => item.name)).toEqual(["looks/bo.png"]);
expect(await unzip(files)).toEqual({ "hero.png": "hero", "looks/ada.png": "ada" });
const archive = new Uint8Array(await (await outputArchive(files, { fetcher })).arrayBuffer());
// Local file header: compression method 0, stored.
expect(archive[8] | (archive[9] << 8)).toBe(0);
});

test("a later file replaces an earlier one at the same name", async () => {
expect(await unzip(outputFiles({ looks: { collection: [["a b", file("first")], ["a_b", file("second")]] } }, locate))).toEqual({ "looks/a_b.png": "second" });
});

test("refuses bytes that are not the recorded file", async () => {
const [hero] = outputFiles({ hero: file("hero") }, locate);
contents.set(hero.digest, bytes("evil"));
await expect(outputArchive([hero], { fetcher })).rejects.toThrow("hero.png is not the file the run recorded.");
contents.set(hero.digest, bytes("hero"));
await expect(outputArchive([{ ...hero, url: locate("0".repeat(64)) }], { fetcher })).rejects.toThrow("hero.png could not be read (HTTP 404).");
});

test("refuses an archive too large for the tab before reading anything", async () => {
let reads = 0;
const counting = (async () => { reads += 1; return new Response(null); }) as unknown as typeof fetch;
const files = outputFiles({ hero: file("hero") }, locate);
await expect(outputArchive(files, { fetcher: counting, limit: 3 })).rejects.toThrow("more than this tab can put in one zip");
expect(reads).toBe(0);
});

test("the controller saves <run>.zip and reports a failure", async () => {
const saved: string[] = [];
const controller = new OutputArchiveController({ fetcher, save: (_, name) => saved.push(name) });
const files = outputFiles({ hero: file("hero") }, locate);
const states: boolean[] = [];
controller.subscribe(() => states.push(controller.getSnapshot().building));
await controller.download(files, "run-01");
expect(saved).toEqual(["run-01.zip"]);
expect(states).toEqual([true, false]);
await controller.download([{ ...files[0], url: locate("0".repeat(64)) }], "run-01");
expect(controller.getSnapshot()).toEqual({ building: false, error: "hero.png could not be read (HTTP 404)." });
expect(saved).toEqual(["run-01.zip"]);
});
});
Loading
Loading