Project-driven simulations: persistent library, runs with provenance, notebook-first analysis#381
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ADR-001: run-outputs worker protocol, snapshot contract, quota ordering, checkpoints cleanup, quick-run/embed storage contexts, input script selection, parameter sweeps, reconciliation rules - ADR-002: lammps-js wheel supply chain, kernel capability boundary, put-on-close visibility, lammps-logfile wheel, COI two-SW plan - ADR-003: rewritten around Atomify Projects design (Projects noun, Home, quick runs, Files/Runs/Notebook tabs, run detail, run lifecycle, sweeps) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ProjectStorage interface with ContentsProjectStorage over the JupyterLite contents schema (IndexedDB via localforage, or in-memory for quick runs/embeds/tests); checkpoint-store cleanup on delete/rename - runs module: run-NNN allocation, snapshot contract, run.json metadata, zombie reconciliation with notebook grace window, sweep expansion - worker protocol: snapshotWorkdir command reads run outputs out of the worker wasm FS (MEMFS reads are asyncify-safe), size-aware - vitest: exclude .claude/ worktrees from test discovery Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per ADR-002 §2-3: jupyter lite build auto-indexes wheels from <repo>/pypi/ (the lite dir is the repo root), so notebooks can `%pip install lammps-js lammps-logfile`. - pypi/lammps_js-1.5.0-py3-none-any.whl is built from the lammps.js repo's python/ package (interim vendoring until the wheel is published upstream — PyPI 404s and the npm tarball excludes python/). Note: the wheel version (1.5.0, from python/pyproject.toml) skews from the npm lammps.js dependency (1.5.1); upstream PR should bump pyproject.toml to track the npm version. - pypi/lammps_logfile-1.1.3-py3-none-any.whl is the pure-python wheel from PyPI (deps numpy/matplotlib ship with pyodide). - Delete jupyterlite/content/lammps_logfile/: the source-directory copy at the contents root becomes unimportable once the notebook cwd is a project directory; the piplite wheel replaces it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per ADR-002 §3/§5 the Jupyter site gains a local build path (it was
CI-only). `make jupyter`:
- installs jupyterlite/requirements.txt + build into a gitignored
.venv-jupyterlite
- runs `jupyter lite build --contents jupyterlite/content
--output-dir public/jupyter` (pypi/ wheels auto-indexed)
- copies node_modules/lammps.js/dist -> public/jupyter/lammps/ so
notebooks can load the engine from {site}/lammps/client.js (fails
early with a clear message if npm install hasn't run)
- runs scripts/jupyter_coi_patch.py public/jupyter
scripts/jupyter_coi_patch.py is ported from lammps.js
examples/notebook/coi_patch.py: it folds the coi-serviceworker trick
into JupyterLite's own service worker (COOP/COEP/CORP on every
response it serves) and injects a one-shot reload bootstrap into the
app pages, so SharedArrayBuffer/KOKKOS work on static hosting. The
asserts stay pinned to jupyterlite-core 0.8.0 internals and fail the
build loudly on a version bump.
The recipe uses single-line commands: macOS ships GNU Make 3.81,
which ignores .ONESHELL.
Also gitignore the build outputs/caches: public/jupyter,
.venv-jupyterlite, .jupyterlite.doit.db, .cache/ (piplite wheel
cache written to the lite dir, i.e. the repo root).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Jupyter site build now needs node_modules/lammps.js/dist (copied into public/jupyter/lammps/), so npm install moves ahead of the Python setup and the raw `jupyter lite build` step becomes `make jupyter` — the same steps as the local build (deps install, lite build with pypi/ wheel indexing, lammps.js dist copy, COI service-worker patch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- projects model: library, active project (files/runs), typed navigation
state, run queue with sweeps, quick-run scratch projects, debounced
editor saves with pre-snapshot flush
- runs execute through the unchanged engine pipeline: each run
materializes a Simulation with id <dirName>/runs/<runId> so all engine
writes land inside the run directory; outputs stream to project storage
on a 3s throttle and fully at completion; run.json records outcome
- simulation model: remove the stale one-way syncFilesJupyterLite (its
outputs never left the worker since the worker migration); run() now
returns {stopReason, errorMessage} for the orchestrator
- AnalyzeNotebook: project-scoped glob-based template (latest run,
cross-run comparison reading run.json vars, lammps-js scripting cell)
- scriptVariables: detect overridable 'variable X equal <number>' params
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Real easy-peasy store (full model composition) over real in-memory project storage; only the engine boundary is faked (LammpsWeb + minimal wasm FS). Covers project creation (notebook template, inputScript inference), the full run loop (snapshot, run.json, output copies, activeRun lifecycle), sweeps and mid-sweep cancellation, failed runs, debounced saves + flush-before-snapshot, quick runs and saveQuickAsProject, deleteProject guarding, zombie-run reconciliation, duplicateProject, and removeFile clearing the designated input script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Foundation for the ADR-003 shell: a persisted dark/light theme in the settings model, an antd ConfigProvider config mirroring the design tokens, and three surgical projects thunks (readFileRaw, listFiles, projectSizes) the run-detail/home/storage screens need. Also brings the projects store integration tests into the tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (ADR-003) Implements the new non-embedded experience per the final design: - Bespoke token-based shell (dark + light via [data-theme]) with Manrope / JetBrains Mono, global sidebar (Home, Example library, projects list with running pulse, New project, theme toggle + Settings + engine chip). - Home (greeting, continue card with last frame.png, create/quick-run cards, recent projects, examples row) and the Example library screen. - Project workspace: header (breadcrumb, displayName + dirName/, Running pill, Share, Run split-button with dropdown, project menu), Files | Runs | Notebook tabs; quick-run banner with Save as project. - Files tab (search, new file, upload + drop-anywhere, input-script chip, run/set-input/edit/download/rename/delete row actions); Monaco editor sub-screen with debounced autosave states and read-only run snapshots. - Runs tab (status rows, vars chips, live progress, sweep group headers with cancel-remaining, queued entries) and run detail (live View pane / frame.png / run-again placeholder, following console, status panel, speed slider, output files). - Modals: New run (input-script chooser, parameter rows with per-variable sweeps, KOKKOS toggle, command preview), New project (blank / example / upload + colors), Settings (theme, rendering, storage meter + persist + per-project sizes), rename, delete (blocked while running). - Deep links (?project&tab&run&file) with history.replaceState sync, autosave flush on hide + refresh on focus, notices and run-completion as toasts (no console modal in the shell). - Embedded mode keeps the legacy path verbatim via src/EmbeddedApp.tsx; View gains an opt-in pane mode; Notebook opens the project's notebook path directly; Examples data loading extracted to useExamples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- runPlan: override diffing, sweep expansion (first variable slowest), command preview, summary/CTA. - deepLink: parse/serialize round trips, sub-screen tab pinning, legacy embed params ignored, unmanaged params preserved. - Shell integration on the real store + memory storage: theme toggle, example library fetch, blank project creation into the workspace, deep-link restore, engine-loading Run button state. - App routing (new shell vs embedded), theme persistence, notebook URLs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mory Driving the shell end-to-end in a real browser surfaced three issues: - frame.png was a black image: WebGL drawing buffers (no preserveDrawingBuffer) are invalid by the time the async capture ran. Capture now waits two animation frames and reads the pixels synchronously (toDataURL) before the frame composites. - Finished runs showed "no log recorded": this wasm build streams LAMMPS output to stdout instead of writing log.lammps. The run loop now persists the captured console output as runs/<id>/log.lammps when the engine wrote none, which the run-detail console renders (ADR-003 §4.6). - Run detail showed memory in TB: simulationStatus.memoryUsage is bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Browser verification showed antd v5's static notification API silently renders nothing under React 19, so the run-finished toast (and every other shell toast) never appeared. The shell now creates a notification.useNotification() instance, renders its context holder inside the themed ConfigProvider, and exposes it via ShellUI.notify; useSimulationNotifications accepts the instance (embedded mode keeps its static fallback unchanged). Also stops labelling the finished-run toast with the stale pre-refresh "running" status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Gate initial render on settings rehydration: easy-peasy persist replays state asynchronously and silently reverts earlier dispatches (bit the test suite via setLammps; same race existed at app boot) - duplicateProject: copy file contents directly so binary files survive (the CreateProjectPayload detour emptied them) - handleRunResult: surface runPostTimestep rejections instead of leaking an unhandled rejection Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven specs driving the real app (vite dev on :5199, real wasm engine, real IndexedDB) with one worker and per-test context isolation: - library: create via modal, reload persistence, rename (dirName stable), duplicate, delete with confirm summary - editor: Monaco autosave indicator, reload persistence, input-script chip, Run-opens-chooser with two scripts + persisted choice - run: full LJ run — live progress, Completed pill, run-001 row, output files, read-only log snapshot, never-blank run detail after reload - sweep: T over 2 values, sequential runs, sweep header + vars chips, run.json vars verified straight from IndexedDB - quickrun: banner + hidden Notebook tab, save-as-project materializes - notebook: iframe targets <dir>/analysis.ipynb (skips if public/jupyter is not built) - theme: data-theme + token background, persists across reload Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Shared ?data=/?script= links route to the legacy shell again (they landed on an empty Home; quick-run import is the follow-up) - Legacy shell (embeds/shares) drops its Notebook pane and 'Analyze in notebook' button — nothing writes JupyterLite storage on that path now - executeNextRun claims the executor slot synchronously (double-click Run could race two engines onto one run id) - snapshotWorkdir can no longer strand the run pipeline: the worker always replies (even on FS walk errors) and the proxy times out after 60s - Invalid JSON in .json/.ipynb saves falls back to a text record instead of throwing away the user's edit (mid-edit autosave data loss) - Binary snapshot inputs reach the engine FS byte-exact instead of being TextDecoder-mangled - run() guard-path undefined now records the run as failed, not completed - Debounced saves stay awaitable while their write is in flight (the pre-snapshot flush could miss an in-flight write and snapshot stale content) - Corrupt persisted settings degrade to defaults instead of a blank page - Example notebooks install lammps-logfile from the piplite wheel (the /drive content copy they imported no longer ships) - vitest excludes e2e/ (Playwright specs are not vitest tests) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request transforms Atomify from a single-simulation tool into a project-driven platform. By unifying the application's working directory with JupyterLite's storage, it enables persistent project management, reproducible run history, and notebook-first analysis. The architecture is supported by a new swappable storage interface and a worker-to-main-thread data streaming protocol, ensuring a seamless experience for parameter sweeps and long-running simulations. Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Ignored Files
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request implements a major architectural redesign of Atomify, introducing a persistent project library, run history, and deep integration with JupyterLite's contents store, alongside a comprehensive Playwright E2E test suite. The review feedback is highly constructive and identifies several critical improvements: implementing a try-finally block in the run execution thunk to prevent lockups on storage errors, resolving a potential memory leak on HomeScreen unmount, ensuring parent directories are created during directory renames, recursively walking subdirectories during working tree snapshots, avoiding call stack limits in base64 conversion, fixing glob matching and file encoding in the python patch script, and adding a fallback for crypto.subtle in non-secure contexts.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
|
||
| let stopReason: string = "failed"; | ||
| let errorMessage: string | undefined; | ||
| const interval = setInterval(() => { | ||
| void copyOutputs(MID_RUN_COPY_CAP); | ||
| }, MID_RUN_COPY_INTERVAL_MS); | ||
| try { | ||
| await storeActions.simulation.newSimulation(simulation); | ||
| for (const file of binaryFiles) { | ||
| getWasm().FS.writeFile(`/${simulation.id}/${file.fileName}`, file.bytes); | ||
| } | ||
| const result = (await storeActions.simulation.run()) as | ||
| | { stopReason: string; errorMessage?: string } | ||
| | undefined; | ||
| // run() returns undefined on its guard paths (engine busy, input | ||
| // script missing from the snapshot) — the run never executed. | ||
| stopReason = result?.stopReason ?? "failed"; | ||
| errorMessage = | ||
| result?.errorMessage ?? | ||
| (result | ||
| ? undefined | ||
| : ((getStoreState() as State<StoreModel>).simulation.lastError ?? | ||
| "The engine did not start this run.")); | ||
| } catch (error) { | ||
| errorMessage = error instanceof Error ? error.message : String(error); | ||
| } finally { | ||
| clearInterval(interval); | ||
| } | ||
|
|
||
| // 4. Final full output copy + run.json + best-effort frame capture. | ||
| await copyOutputs(undefined); | ||
| const lammpsState = (getStoreState() as State<StoreModel>).simulation; | ||
| // This wasm build streams LAMMPS output to stdout instead of writing a | ||
| // log file; persist the captured console as the run's log.lammps | ||
| // (ADR-003 §4.6 renders finished-run consoles from it). A log the engine | ||
| // did write itself wins. | ||
| try { | ||
| const logPath = `${RUNS_DIR}/${runId}/log.lammps`; | ||
| const engineLog = await storage.stat(request.dirName, logPath); | ||
| if (!engineLog && lammpsState.lammpsOutput.length > 0) { | ||
| await storage.write( | ||
| request.dirName, | ||
| logPath, | ||
| lammpsState.lammpsOutput.join("\n") + "\n", | ||
| ); | ||
| } | ||
| } catch { | ||
| // The log is a convenience copy; never fail the run over it. | ||
| } | ||
| const existing = await readRunMeta(storage, request.dirName, runId); | ||
| runMeta = { | ||
| ...(existing ?? runMeta), | ||
| status: | ||
| stopReason === "canceled" | ||
| ? "canceled" | ||
| : stopReason === "failed" | ||
| ? "failed" | ||
| : "completed", | ||
| finishedAt: new Date().toISOString(), | ||
| error: stopReason === "failed" ? errorMessage : undefined, | ||
| stats: { | ||
| timesteps: lammps.getTimesteps(), | ||
| numAtoms: lammps.getNumAtoms(), | ||
| wallSeconds: | ||
| (Date.now() - new Date(runMeta.startedAt).getTime()) / 1000, | ||
| }, | ||
| }; | ||
| await writeRunMeta(storage, request.dirName, runMeta); | ||
| const frame = await captureViewport(); | ||
| if (frame) { | ||
| try { | ||
| await storage.write( | ||
| request.dirName, | ||
| `${RUNS_DIR}/${runId}/.atomify/frame.png`, | ||
| frame, | ||
| ); | ||
| } catch { | ||
| // Frame capture is decorative; never fail the run over it. | ||
| } | ||
| } | ||
|
|
||
| actions.setActiveRun(undefined); | ||
| await actions.refreshActive(); | ||
| const state = getState(); | ||
| if (state.screen.name !== "project" || state.screen.runId !== runId) { | ||
| actions.setNotice( | ||
| `Run #${runId.replace(/^run-0*/, "")} ${runMeta.status}.`, | ||
| ); | ||
| } | ||
| if (lammpsState.running) { | ||
| // Defensive: the engine reported running after run() settled. | ||
| return; | ||
| } | ||
| // 5. Advance the sweep queue. | ||
| await actions.executeNextRun(); |
There was a problem hiding this comment.
If any storage operation (such as writeRunMeta, readRunMeta, or copyOutputs) throws an error (e.g., due to IndexedDB quota limits), the thunk execution will stop immediately. This leaves activeRun permanently set, locking up the run loop and preventing any future runs from starting. Wrapping the execution in a try-finally block ensures that activeRun is always cleared and the queue advances.
try {
let stopReason: string = "failed";
let errorMessage: string | undefined;
const interval = setInterval(() => {
void copyOutputs(MID_RUN_COPY_CAP);
}, MID_RUN_COPY_INTERVAL_MS);
try {
await storeActions.simulation.newSimulation(simulation);
for (const file of binaryFiles) {
getWasm().FS.writeFile("/" + simulation.id + "/" + file.fileName, file.bytes);
}
const result = (await storeActions.simulation.run()) as
| { stopReason: string; errorMessage?: string }
| undefined;
stopReason = result?.stopReason ?? "failed";
errorMessage =
result?.errorMessage ??
(result
? undefined
: ((getStoreState() as State<StoreModel>).simulation.lastError ??
"The engine did not start this run."));
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
} finally {
clearInterval(interval);
}
await copyOutputs(undefined);
const lammpsState = (getStoreState() as State<StoreModel>).simulation;
try {
const logPath = RUNS_DIR + "/" + runId + "/log.lammps";
const engineLog = await storage.stat(request.dirName, logPath);
if (!engineLog && lammpsState.lammpsOutput.length > 0) {
await storage.write(
request.dirName,
logPath,
lammpsState.lammpsOutput.join("\n") + "\n",
);
}
} catch {
// The log is a convenience copy; never fail the run over it.
}
const existing = await readRunMeta(storage, request.dirName, runId);
runMeta = {
...(existing ?? runMeta),
status:
stopReason === "canceled"
? "canceled"
: stopReason === "failed"
? "failed"
: "completed",
finishedAt: new Date().toISOString(),
error: stopReason === "failed" ? errorMessage : undefined,
stats: {
timesteps: lammps.getTimesteps(),
numAtoms: lammps.getNumAtoms(),
wallSeconds:
(Date.now() - new Date(runMeta.startedAt).getTime()) / 1000,
},
};
await writeRunMeta(storage, request.dirName, runMeta);
const frame = await captureViewport();
if (frame) {
try {
await storage.write(
request.dirName,
RUNS_DIR + "/" + runId + "/.atomify/frame.png",
frame,
);
} catch {
// Frame capture is decorative; never fail the run over it.
}
}
} catch (error) {
console.error("Run execution failed:", error);
} finally {
actions.setActiveRun(undefined);
await actions.refreshActive().catch(() => {});
const lammpsState = (getStoreState() as State<StoreModel>).simulation;
if (!lammpsState.running) {
await actions.executeNextRun().catch(() => {});
}
}| dirName, | ||
| }); | ||
| if (frame instanceof Uint8Array && frame.length > 0) { | ||
| const bytes = new Uint8Array(frame); | ||
| objectUrl = URL.createObjectURL( | ||
| new Blob([bytes.buffer as ArrayBuffer], { type: "image/png" }), | ||
| ); | ||
| if (mounted) { | ||
| setFrameUrl(objectUrl); | ||
| } |
There was a problem hiding this comment.
If the component unmounts while readFileRaw is pending, mounted is set to false. However, URL.createObjectURL is still called when the promise resolves, and since the cleanup function has already run, this Object URL is leaked. Guarding the block with if (!mounted) return; prevents this memory leak.
if (frame instanceof Uint8Array && frame.length > 0) {
if (!mounted) {
return;
}
const bytes = new Uint8Array(frame);
objectUrl = URL.createObjectURL(
new Blob([bytes.buffer as ArrayBuffer], { type: "image/png" }),
);
if (mounted) {
setFrameUrl(objectUrl);
}
return;
}
|
|
||
| if (record.type === "directory") { | ||
| const prefix = `${fromFull}/`; | ||
| const keys = await this.files.keys(); | ||
| for (const key of keys) { | ||
| if (key === fromFull || key.startsWith(prefix)) { | ||
| const target = toFull + key.slice(fromFull.length); | ||
| await this.moveKey(key, target); | ||
| } | ||
| } | ||
| return; |
There was a problem hiding this comment.
When renaming a directory, we must ensure that the parent directories of the new target directory exist in the store. Currently, ensureParents is only called for file renames (line 219). Moving await this.ensureParents(toFull) to the top of the rename method ensures that directory renames also create any missing ancestor directory records, preventing JupyterLite from throwing errors when accessing the renamed path.
| if (record.type === "directory") { | |
| const prefix = `${fromFull}/`; | |
| const keys = await this.files.keys(); | |
| for (const key of keys) { | |
| if (key === fromFull || key.startsWith(prefix)) { | |
| const target = toFull + key.slice(fromFull.length); | |
| await this.moveKey(key, target); | |
| } | |
| } | |
| return; | |
| await this.ensureParents(toFull); | |
| if (record.type === "directory") { | |
| const prefix = fromFull + "/"; | |
| const keys = await this.files.keys(); | |
| for (const key of keys) { | |
| if (key === fromFull || key.startsWith(prefix)) { | |
| const target = toFull + key.slice(fromFull.length); | |
| await this.moveKey(key, target); | |
| } | |
| } | |
| return; | |
| } |
| options?: { sizeCap?: number }, | ||
| ): Promise<{ copied: string[]; gaps: RunMeta["snapshotGaps"] }> { | ||
| const sizeCap = options?.sizeCap ?? SNAPSHOT_SIZE_CAP; | ||
| const entries = await storage.list(dirName); |
There was a problem hiding this comment.
storage.list(dirName) only lists the direct children of the project root and is not recursive. If the working tree contains subdirectories (e.g., uploaded force field or structure folders), they will be ignored and not snapshotted, causing the simulation to fail if it references files inside them. Implementing a recursive walk ensures all nested files are correctly snapshotted.
const copied: string[] = [];
const gaps: NonNullable<RunMeta["snapshotGaps"]> = [];
const walk = async (subdir?: string): Promise<void> => {
const entries = await storage.list(dirName, subdir);
for (const entry of entries) {
if (entry.type === "directory") {
if (entry.path === "runs" || entry.path === ".atomify") {
continue;
}
await walk(entry.path);
} else if (isSnapshotted(entry)) {
if (entry.size > sizeCap) {
const content = await storage.read(dirName, entry.path);
gaps.push({
path: entry.path,
size: entry.size,
sha256: await sha256Hex(content),
});
} else {
const content = await storage.read(dirName, entry.path);
await storage.write(
dirName,
RUNS_DIR + "/" + runId + "/" + entry.path,
content,
);
copied.push(entry.path);
}
}
}
};
await walk();| export function bytesToBase64(bytes: Uint8Array): string { | ||
| let binary = ""; | ||
| for (let i = 0; i < bytes.length; i += CHUNK) { | ||
| binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); | ||
| } | ||
| return btoa(binary); | ||
| } |
There was a problem hiding this comment.
Using String.fromCharCode with the spread operator on large subarrays can exceed the maximum call stack size in some JavaScript engines. A cleaner, faster, and safer approach in modern environments is to decode the bytes using TextDecoder with the 'latin1' encoding, which maps bytes 0-255 directly to codepoints 0-255.
export function bytesToBase64(bytes: Uint8Array): string {
return btoa(new TextDecoder("latin1").decode(bytes));
}| </script>""" | ||
|
|
||
| patched = 0 | ||
| for page in sorted(out.glob("*/index.html")): |
There was a problem hiding this comment.
out.glob("*/index.html") only matches index.html files that are exactly one directory level deep. If there is an index.html at the root of the built JupyterLite site, it will be missed. Using out.rglob("index.html") recursively finds all index.html files, and the subsequent jupyter-config-data check will safely filter out any non-app pages.
| for page in sorted(out.glob("*/index.html")): | |
| for page in sorted(out.rglob("index.html")): |
| const bytes = | ||
| typeof content === "string" ? new TextEncoder().encode(content) : content; | ||
| const digest = await crypto.subtle.digest( | ||
| "SHA-256", | ||
| bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && | ||
| bytes.byteLength === bytes.buffer.byteLength | ||
| ? bytes.buffer | ||
| : bytes.slice().buffer, | ||
| ); | ||
| return [...new Uint8Array(digest)] | ||
| .map((byte) => byte.toString(16).padStart(2, "0")) | ||
| .join(""); | ||
| } |
There was a problem hiding this comment.
crypto.subtle is only available in secure contexts (HTTPS or localhost). If Atomify is loaded over HTTP (e.g., in some local network deployments), crypto.subtle will be undefined and calling crypto.subtle.digest will throw a TypeError. Adding a fallback prevents runtime crashes in non-secure contexts.
async function sha256Hex(content: string | Uint8Array): Promise<string> {
const bytes =
typeof content === "string" ? new TextEncoder().encode(content) : content;
if (typeof crypto === "undefined" || !crypto.subtle) {
return "unsafe-context-" + bytes.length + "-" + Date.now();
}
const digest = await crypto.subtle.digest(
"SHA-256",
bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 &&
bytes.byteLength === bytes.buffer.byteLength
? bytes.buffer
: bytes.slice().buffer,
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}| # --- 1. teach the service worker to add the isolation headers ------------- | ||
|
|
||
| sw_path = out / "service-worker.js" | ||
| sw = sw_path.read_text() |
There was a problem hiding this comment.
Reading and writing files without specifying an encoding can lead to platform-dependent encoding issues (e.g., on Windows where the default encoding is cp1252). Specifying encoding="utf-8" ensures consistent behavior across all platforms.
| sw = sw_path.read_text() | |
| sw = sw_path.read_text(encoding="utf-8") |
"From example" previously offered a cramped strip of tiles. The modal now widens to 920px when that source is selected and renders a searchable, scrollable 4-column card grid over the whole example library: the search input filters by title, description and keywords (like the Examples screen), cards show the image plus the first keyword as a category chip, and the picked card keeps the accent highlight. Home's "Start from an example" row still shows the first four examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new shell's run detail mounted View in pane mode, which skips the legacy simulation summary — losing the live compute/fix/variable readouts and the real-time figures. The right panel now renders a RunAnalysis section for the live run: computes, fixes and variables from the simulationStatus store (grouped, accent link rows per the design mock), scalar values inline, and 1D entries open the real-time dygraphs figure in a token-styled ModalShell with CSV export. Finished runs keep hiding the section — outputs and the log are the record. The dygraphs core is extracted from components/Figure.tsx into an exported FigureGraph (the antd Figure wraps it unchanged) so the shell reuses the same plot lifecycle, per-timestep refresh and syncDataPoints wiring. Its sync effect is now keyed on the modifier NAME instead of the object: store updates recreate the entry object every toggle, and identity-keyed cleanup flipped syncDataPoints off/on in a render loop. tokens.css scopes theme-aware dygraph label/legend colors under .shell-figure. The console strip is also collapsible now: the header bar stays as a slim always-visible bar (clicking it or the chevron toggles), the viewport flexes into the reclaimed height, and the choice is remembered per session. e2e: run.spec adds an msd compute and a longer run, then asserts the live analysis section lists it, the figure opens/closes, and the console strip collapses/expands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the merge conflict that prevented GitHub from creating the PR merge ref for #381 — with the PR in CONFLICTING state, no pull_request check suite could ever be created, which is why CI never triggered. Resolutions: - .github/workflows/deploy.yaml: keep the branch's `make jupyter` build (it already applies the COOP/COEP service-worker patch via scripts/jupyter_coi_patch.py); drop main's standalone patch-jupyterlite-sw.mjs step and remove the now-unused script. - index.html: keep both main's service-worker lifecycle (?sw-reset escape hatch) and the branch's shell fonts; point the comment at jupyter_coi_patch.py. - src/index.tsx: combine main's sw-reset boot guard with the branch's rehydration-gated mount. - .gitignore: union of both sides, deduplicated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plain ?data=/?script= URLs (without embed=true) no longer route to the legacy shell — they land on the projects Home screen. The embed=true iframe routing is untouched. The Share modal accordingly always bakes embed=true&autostart=true into the generated URL and its copy now says "Embed link"; the embed/autostart checkboxes are gone since there is no non-embed variant to choose anymore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…threads The New Run modal's toggle passed useKokkos through startRuns but nothing consumed it — acceleration was decided solely by the script's own `suffix kk` marker. Now executeNextRun injects the marker into the materialized run copy (working tree and run snapshot keep the original text) when the toggle is on and the script doesn't already opt in; syncFilesWasm then sees the marker and skips its serial-styles preprocessing, so the run executes fully accelerated. Toggle off leaves opted-in scripts alone — the toggle defaults from the script, so off only means "don't inject". Thread count: the engine's Kokkos pool is NOT per-run configurable — Kokkos::initialize is one-shot per wasm module (first startWithArgs at engine load in the worker; reset() reuses the pool) and the module's pthread pool is sized at load (PTHREAD_POOL_SIZE 8). The load-time value was hardwareConcurrency capped at 8; it is now the shared KOKKOS_THREADS constant (4), so the modal's "-sf kk -k on t 4" preview and the "×4 threads" toggle copy match what actually runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebuilt from a clean `git archive` of lammps.js feat/atomify-variant (python/ bumped to 1.5.1) with `python -m build --wheel`, replacing the vendored 1.5.0 wheel. Verified `make jupyter` indexes it: the piplite all.json now lists lammps-js 1.5.1. Note: the new variant="atomify" kwarg needs the next lammps.js npm release for the kernel-side client (upstream PR pending) — until then the kernel keeps using the current client behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Metrics (utils/metrics track(), no PII — dirName never included):
- Store thunks (survive UI refactors): Project.Open/Rename/Duplicate/
Delete/Export/Import, Run.Start {origin: button|file|sweep, kokkos,
hasVars}, Run.Finish {status, wallSeconds, timesteps}, Sweep.Create
{varCount, runCount}, File.Rename/Delete, InputScript.Set.
- UI-only events in the shell: File.Create/Upload (FilesTab knows the
gesture), QuickRun.Start {exampleId}, Example.UseAsProject {exampleId},
Notebook.Open, Theme.Change {theme}.
Export/import (fflate):
- exportProject thunk zips the project tree via ProjectStorage reads
(working tree + notebook + .atomify/project.json; runs/ only for the
"Download with runs" variant). Two ⋯-menu entries:
project-menu-download / project-menu-download-runs.
- The New Project modal's Upload path imports a single .zip as a whole
project: fflate-unpacked, .atomify/project.json supplies displayName/
inputScript/color defaults (the modal's name field wins), entries are
written binary-safe through the contents-schema classification, and a
zip's single common root folder is stripped.
- Unit tests: export→import round-trip on memory storage incl. a binary
file and run history. E2e: download-event round-trip in library.spec.ts.
Also fixes a real pre-existing bug the new e2e caught: the upload input's
onChange read event.target.files inside the functional setUploads updater,
which runs after `value = ""` has already emptied the FileList — picked
files were silently dropped. Files are captured eagerly now (same for the
drop handler's neutered dataTransfer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Formatting-only follow-up for files touched by the previous commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User decision: the iframe/embed use case (the book) is moving to a different mechanism, and URL-encoded sharing already died on GitHub Pages' ~2 KB URL limit. The projects shell is now the whole app; project zip export/import is the sharing story until a backend exists. Deleted: EmbeddedApp, the invisible-tabs Main shell, useMenuItems, AutoStartSimulation, AppModals, ShareSimulation, Console, legacy Edit/Examples/Settings/NewSimulation containers, RunInCloud (user confirmed), LoadingSimulationScreen, SimulationSummaryModal (already unreferenced), useEmbeddedMode, utils/embeddedMode, the utils/embed codec+proto, EmbedConfig, the legacy darkThemeConfig, and all their tests. Pruned embed branches from survivors: View (isEmbeddedMode prop and embedConfig overrides; pane mode and the full-screen mode remain), ResponsiveSimulationSummary, store/app (selectedMenu initializer), metrics (getEmbeddingParams; track() unchanged), simulation (showSimulationBox/showWalls fields — nothing sets them anymore; the engine-driver model itself is untouched, executeNextRun still runs through newSimulation/run). App.tsx always renders Shell. Dependencies dropped (used only by deleted code): protobufjs, react-iframe, react-use, react-terminal-ui, react-markdown, rehype-katex, rehype-external-links, remark-math, katex. ADR-001 §6 and ADR-003 gained a 2026-07-11 amendment documenting the removal and pointing at zip export/import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI "Run Prettier" step (npx prettier .) ran for 35+ minutes on this branch: make jupyter (which now runs before prettier) creates the ~500 MB .venv-jupyterlite in the repo root, and prettier was formatting and printing the whole venv to the Actions log. Ignore the venv plus dist/pypi/test-results/playwright-report/.claude, and backend/ (a separate service with its own tooling). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The design's Parameters explainer ends "To add or remove one, edit the script." with "edit the script" linking to where the user edits it. The shell dropped that final sentence; add it back as an accent link reusing the existing onGoToFiles handler (closes the modal, opens the Files tab). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review findings at the storage layer: - bytesToWriteContent decoded every extension outside the 11-entry binary table as UTF-8, corrupting binary run artifacts with unlisted extensions (write_restart output, .dcd/.xtc dumps) into U+FFFD soup. Unknown extensions are now content-sniffed (NUL byte or invalid UTF-8 stays bytes), and restart/dcd/xtc join the binary extension table. - reconcileRuns interrupted "running" runs the moment they were unowned, which raced the claim window (allocateRunDir placeholder, snapshot in progress) of the run this session is starting. Runs of any origin whose startedAt is younger than FRESH_RUN_GRACE_MS (15 s) are now left alone; a genuinely interrupted young run is caught by the next reconcile pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Simulation.Start/Stop/New still sent simulationId, which under the new shell is "<project-dir-slug>/runs/run-NNN" — the slug derives from the user's project name (user content, PII policy), and the failed-run Stop also shipped errorMessage, which quotes script content. Both are gone; stopReason + numeric metricsData remain, and the projects-side Run.Start/Run.Finish events already carry the sanitized signal. - state.lammpsOutput is a display buffer capped at the last 2000 lines, but run persistence built log.lammps from it, so long runs lost their header and thermo columns. A module-scope uncapped accumulator (reset on every run start, exposed via getFullLammpsOutput) now feeds persistence; the display cap is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd capture fixes Review findings in the projects store: - copyOutputs copied the engine's (possibly kokkos-preprocessed) input files back over the run snapshot, losing the authored text ADR-001 §2 promises. Files snapshotWorkingTree copied are now excluded — only genuine outputs come back. (Trade-off: a run that modifies an input file in place keeps the authored snapshot; provenance wins.) - duplicateProject copied only top-level files, silently dropping nested directories (e.g. potentials/ from an imported zip). It now walks the working tree with copyTree, excluding runs/ and .atomify/. - exportProject and duplicateProject read storage without awaiting flushPendingSaves, so edits inside the 500 ms debounce window were missing from the zip / the copy. Both flush first now. - readFile cached runs/ paths forever, serving stale mid-run copies and — because run numbers are reused after deleteRun — a deleted run's log for its successor. runs/ paths (engine outputs, not editor buffers) now always read fresh; working-tree files stay cached. - executeNextRun claimed activeRun with runId "pending" through the snapshot, leaving the freshly allocated run-NNN unowned for reconcile during that window; the real run id is recorded immediately after allocateRunDir. - importProject now validates every zip entry name up front (a "%"/".." entry fails the import before anything is created) and, if a write fails mid-loop, deletes the half-created project and rethrows with the failing entry — no orphan in the library. - captureViewport grabbed the first canvas in the DOM, which after View unmounts is Monaco's minimap (or a notebook chart); it now targets the visualizer container (#canvas-container) and returns null when absent. - log.lammps persistence reads the uncapped console accumulator instead of the 2000-line display buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design spec: the sweep group header reads "Sweep - L = 12 -> 18 - 3 runs". Derives the varying variable and its first->last value from the group's run vars, chronologically ordered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Project-driven simulations: persistent library, runs with provenance, notebook-first analysis
Implements the architecture in docs/adr/001–003 (written, agent-reviewed, and iterated in this branch) and the visual design from the Claude Design project (
Atomify Projects.dc.html).What this is
Atomify becomes project-driven: a persistent Projects library (IndexedDB), where each project is a working tree of files plus an append-only history of runs — each run holding a snapshot of the inputs it ran with, its outputs, and metadata (
run.json). The Jupyter notebook operates on the same filesystem the app writes, so app runs, notebook analysis, and Python-scripted runs all see one project directory.Architecture (ADR-001)
ProjectStorageinterface (src/storage/). One implementation (ContentsProjectStorage) over two key-value backends: localforage/IndexedDB (the library) and in-memory (quick runs, embeds, tests). A future API backend is a third implementation — the interface mirrors Jupyter's contents API shape.snapshotWorkdirworker command reads outputs out (ASYNCIFY-safe — MEMFS reads are pure JS), streamed to storage on a 3 s throttle mid-run and fully at completion.Simulationwithid = <dirName>/runs/<runId>, so kokkos preprocessing, vars wrapper, and all writes land inside the run directory.runningruns becomeinterruptedon open (notebook-origin runs get a grace window); metadata-lessruns/*dirs list as external runs.Notebook (ADR-002)
analysis.ipynb, generated once, discovers runs by glob (runs/*/log.lammps) and reads sweep variables from each run'srun.json— cross-run analysis works without regeneration.lammps-jsPython package bundled (wheel vendored inpypi/, indexed by piplite) — notebooks can run LAMMPS in the kernel and script parameter sweeps in Python; outputs land in the project.lammps-logfilenow ships as a wheel (the old content-dir copy broke under project-scoped notebook cwds).make jupyterbuilds the full site locally (JupyterLite + lammps.js client + COI service-worker patch ported from lammps.js); CI uses the same target.Product & design (ADR-003)
variable X equal <number>parameters, per-variable value/range sweep, cartesian product runs sequentially with grouped rows and cancel-remaining; overrides recorded inrun.json.vars.Testing
Known limitations / follow-ups
Shared
?data=/?script=links open in the legacy editor shell (exactly as on main); importing shares as quick-run projects with the "Save as project" banner is the follow-up. The legacy shell no longer has a Notebook pane (nothing writes JupyterLite storage on that path anymore).Kernel-side engine covers the MOLECULE (+KOKKOS) package set until an upstream lammps.js
variant: "atomify"option lands; two small upstream PRs planned (wheel publication, variant option). Vendored wheel is 1.5.0 vs npm 1.5.1 (tracked in commit message).Per-run thread-count picker omitted (thread pool is fixed at wasm load); the multithreading toggle maps to the script-level KOKKOS opt-in.
Run snapshots byte-duplicate unchanged files (size-capped); content-addressed dedup is future work behind the storage interface.
Trajectory replay for historical runs is out of scope; run detail shows the final frame capture + persisted log.
🤖 Generated with Claude Code