Skip to content

Project-driven simulations: persistent library, runs with provenance, notebook-first analysis#381

Merged
andeplane merged 35 commits into
mainfrom
feat/simulations-library
Jul 12, 2026
Merged

Project-driven simulations: persistent library, runs with provenance, notebook-first analysis#381
andeplane merged 35 commits into
mainfrom
feat/simulations-library

Conversation

@andeplane

Copy link
Copy Markdown
Owner

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)

  • Single project filesystem: projects live directly in JupyterLite's contents IndexedDB ("JupyterLite Storage") using its record schema — "sync with the notebook" holds by construction, not by a sync protocol.
  • Swappable storage: everything goes through the ProjectStorage interface (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.
  • Run outputs data path: the engine's wasm FS lives in a worker whose main-thread bridge was write-only; a new snapshotWorkdir worker 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.
  • Runs execute through the untouched engine pipeline: each run materializes a Simulation with id = <dirName>/runs/<runId>, so kokkos preprocessing, vars wrapper, and all writes land inside the run directory.
  • Reconciliation: zombie running runs become interrupted on open (notebook-origin runs get a grace window); metadata-less runs/* dirs list as external runs.

Notebook (ADR-002)

  • Per-project analysis.ipynb, generated once, discovers runs by glob (runs/*/log.lammps) and reads sweep variables from each run's run.json — cross-run analysis works without regeneration.
  • lammps-js Python package bundled (wheel vendored in pypi/, indexed by piplite) — notebooks can run LAMMPS in the kernel and script parameter sweeps in Python; outputs land in the project.
  • lammps-logfile now ships as a wheel (the old content-dir copy broke under project-scoped notebook cwds).
  • make jupyter builds 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)

  • Home (continue-where-you-left-off, recent projects, start-from-example), Example library ("Use as project" / "Quick run"), project workspace (Files | Runs | Notebook tabs, editor with autosave states, run detail with live viewport + following console + stats/outputs panel).
  • Input script is explicit project state — the Run button prompts to choose when ambiguous; Files shows the chip, per-script "Set as input" / "Run this file".
  • Parameter sweeps: the New Run modal detects variable X equal <number> parameters, per-variable value/range sweep, cartesian product runs sequentially with grouped rows and cancel-remaining; overrides recorded in run.json.vars.
  • Quick runs are ephemeral scratch projects with a "Save as project" banner — tire-kicking never litters the library.
  • Delete flows show run count + size and are blocked mid-run; Settings has a Storage tab (usage meter, per-project sizes, persist toggle); dark + light themes from the design token system.
  • Embedded/share-link mode keeps the legacy pipeline untouched.

Testing

  • Unit tests: storage layer (schema fidelity, checkpoints cleanup, rename/delete semantics), runs module (allocation, snapshot contract, reconciliation, sweep expansion), notebook template, script-variable parsing.
  • Store-level integration tests: the full create → edit → run → sweep → persist loop against real storage with a controllable fake engine.
  • Playwright end-to-end: real browser + real WASM engine — create project, edit with autosave surviving reload, run to completion, sweep, quick-run → save-as-project, delete.
  • CI: vitest + build + jupyter build.

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

andeplane and others added 19 commits July 11, 2026 09:19
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>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, 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

  • Project-driven architecture: Atomify now features a persistent library of projects using IndexedDB, where each project maintains a working tree of files and an append-only history of simulation runs with full provenance.
  • JupyterLite integration: The Jupyter notebook now operates directly on the project's filesystem, eliminating the need for sync protocols. Project-scoped notebooks are generated automatically and discover simulation runs via glob patterns.
  • UI Shell Redesign: The application has been migrated to a new, project-centric UI shell featuring a library view, run history, and a detailed run-view with live viewport and console following, replacing the legacy modal-based console.
  • Parameter Sweeps: Added support for parameter sweeps that execute sequentially, with variable overrides recorded in run metadata, enabling cross-run analysis directly within the notebook.
  • Worker Data Path: Implemented a new worker protocol (snapshotWorkdir) that allows the main thread to stream simulation outputs from the WASM engine to storage mid-run, ensuring outputs are available for analysis even before completion.
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
  • Ignored by pattern: .github/workflows/** (1)
    • .github/workflows/deploy.yaml
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/store/projects.ts
Comment on lines +932 to +1026

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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(() => {});
      }
    }

Comment thread src/shell/HomeScreen.tsx
Comment on lines +197 to +206
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
        }

Comment on lines +208 to +218

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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;
}

Comment thread src/storage/runs.ts
options?: { sizeCap?: number },
): Promise<{ copied: string[]; gaps: RunMeta["snapshotGaps"] }> {
const sizeCap = options?.sizeCap ?? SNAPSHOT_SIZE_CAP;
const entries = await storage.list(dirName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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();

Comment on lines +95 to +101
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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")):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
for page in sorted(out.glob("*/index.html")):
for page in sorted(out.rglob("index.html")):

Comment thread src/storage/runs.ts
Comment on lines +282 to +294
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("");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
sw = sw_path.read_text()
sw = sw_path.read_text(encoding="utf-8")

andeplane and others added 9 commits July 11, 2026 13:33
"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>
andeplane and others added 7 commits July 11, 2026 15:00
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>
@andeplane andeplane merged commit 846beff into main Jul 12, 2026
2 checks passed
@andeplane andeplane deleted the feat/simulations-library branch July 12, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant