diff --git a/CHANGELOG.md b/CHANGELOG.md index faa501d..6c8570c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ See [docs/changelog-template.md](docs/changelog-template.md) for formatting your - [#208](https://github.com/2listic/dealiiX-platform/pull/208) You can now give a single run or a pipeline run a custom name before submitting (remote mode); it's used to name the run's output folder instead of a bare timestamp, making it easy to find on disk or in the visualizer. Leaving the name blank keeps today's timestamp-based naming. If the same name is submitted twice, the second run gets a unique suffix instead of overwriting the first. The Jobs table also gains a "Working Directory" column so you can see where each job's files landed. - [#207](https://github.com/2listic/dealiiX-platform/issues/207) The Pipeline editor gains "Export pipeline" and "Import pipeline" buttons: export downloads the current pipeline (all stages, connections, and per-stage settings) as a JSON file; import loads one back in, replacing the canvas. +### UI/UX + +- [#204](https://github.com/2listic/dealiiX-platform/issues/204) Execution mode is now chosen from an always-visible selector in the top-right corner instead of the Settings modal: one dropdown picks the location (local/remote) and another picks the mode (coral, executable, or pipeline). Switching is instant and no longer re-probes the backend, and the app reopens in the last selected mode. Pipeline mode is available only in remote (and disables the local option), matching that pipelines run remotely. Each location now keeps its own validated node registry and parameters, so switching between local and remote — or between coral and executable — never runs one target's configuration against another's; importing nodes merges into the current location's registry rather than replacing it. In Settings, the "Execution paths" section keeps location/backend-kind radios, but they now only choose which combination to configure and **Validate & Sync** (renamed from "Save & Sync Execution") — this no longer changes the active mode, so you can set up and validate any of the four combinations without leaving your current view. A combination that hasn't been validated yet prompts you to open Settings and its runs are blocked. The separate Run controls are unified into a single sidebar Run button that runs the graph in single mode and the pipeline in pipeline mode; the pipeline editor's own toolbar (add stage, import/export, run) moved into the sidebar. + ## [1.4.1] - 2026-07-14 ### Canvas-graph diff --git a/CLAUDE.md b/CLAUDE.md index d584574..69c9eb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,19 +89,20 @@ The application uses two JSON protocols to communicate with CORAL: Stores use Svelte 5 runes (`.svelte.ts` files): - `nodes.svelte.ts` - Central store for flow nodes/edges. Exports `getNodes()`, `getNodesSnapshot()`, `setNodes()`, `getEdges()`, `getEdgesSnapshot()`, `setEdges()`, `addNode()`, `addEdge()`, `removeNode()`, `updateLastNodeId()`. -- `registryStore.svelte.ts` - Registry for available node types (both standard and network). Exports `setRegistry()`, `getNodeData(type)`, `getAvailableNodes()`, `isNodeInRegistry(type)` for standard nodes; and `addNetworkNode(key, data)`, `removeNetworkNode(name)`, `getNetworkNodeDefinition(name)`, `getStoredNetworkNodes()`, `isNodeInNetworkNodes(name)` for subgraph nodes. Both registries are persisted in electron-store and loaded at startup. +- `registryStore.svelte.ts` - Registry for available node types (both standard and network). Standard nodes are kept **per execution location** (`local`/`remote`). **Reads** resolve an internal `activeLocation` (set via `setActiveLocation(location)`; App syncs it from `executionSelectionState.location` via an effect — no `settingsState` import); **writes take an explicit location** so they never move the read cursor: `setRegistry(data, location)` (replace that location's registry), `mergeRegistry(data, location)` (merge into it — used by manual "Import Nodes"). Read APIs `getNodeData(type)`, `getAvailableNodes()`, `isNodeInRegistry(type)` resolve `activeLocation`. Subgraph nodes are location-agnostic: `addNetworkNode(key, data)`, `removeNetworkNode(name)`, `getNetworkNodeDefinition(name)`, `getStoredNetworkNodes()`, `isNodeInNetworkNodes(name)`. Persisted in electron-store (`registered_nodes` as `{ local, remote }`, with best-effort migration from the old flat shape; `registered_network_nodes`) and loaded at startup. - `graphStack.svelte.ts` - Navigation context stack for entering/exiting subnetworks. `graphStackState` has `breadcrumbs`, `canGoBack`, `currentLabel` reactive getters, plus `pushContext()`, `collapseToParent()`, `updateTopContext()`, `updateParentContext()`, `reset()` mutators, and `syncCurrent()` which snapshots the live canvas into the top stack entry before any navigation action. `graphHistoryState` exposes the per-level undo/redo API: `canUndo`/`canRedo` reactive getters; `checkpoint()` (single-step actions), `begin()`/`commit()` (multi-step gestures), `undo()`, `redo()`, `clearHistory()`. Each `GraphContext` holds `current: HistoryEntry` (navigation snapshot), `past: HistoryEntry[]`, and `future: HistoryEntry[]`. - `graphNavigation.svelte.ts` - High-level subnetwork navigation actions built on top of `graphStack`. Exports `enterSubnetwork(nodeId)` (drills into a subnetwork node), `loadParentGraph()` (navigates back, persisting edits), and `renameCurrentSubnetwork(name)` (renames without navigating away). - `nodeIdCounter.svelte.ts` - Isolated store for generating unique node IDs (`getNextNodeId()`). Kept separate from `nodes.svelte.ts` to avoid importing electron side-effects in utility/test contexts. - `colorModeStore.svelte.ts` - Light/dark theme state. `colorModeState.value` (get/set) and `colorModeState.toggle()`. Setting automatically syncs to electron IPC (`set-theme`) and persists to electron-store under `colorMode`. -- `parametersStore.svelte.ts` - Transient store for the CORAL parameter tree (loaded from a run). `parametersState.value` (get/set) and `parametersState.snapshot` (non-reactive copy). +- `parametersStore.svelte.ts` - Store for the executable/CORAL parameter tree, kept **per execution location** and persisted (`execution_parameters`). `parametersState.value` (get/set) and `parametersState.snapshot` resolve an internal `activeLocation` (set via `setActiveLocation(location)`); `setValueFor(location, value)` writes a specific location's tree without moving the cursor (used when validating a non-active target from Settings). +- `executionSelection.svelte.ts` - The active **execution selection** — which `location` (`local`/`remote`) and `backendKind` (`coral`/`executable`) the UI points at. Pure renderer selection state (Electron never reads it), kept separate from the persisted runner config in `settingsStore`; persisted under `execution_selection` so the app reopens in the same mode. `executionSelectionState` exposes getters `location`, `backendKind`, `isCoralMode`, `isExecutableMode` and async setters `setLocation(location)`, `setBackendKind(kind)`. `ExecutionBadge` writes it directly (the mode dropdown maps to `viewModeState` + `setBackendKind`); `App.svelte` syncs the registry/params `activeLocation` from it via an effect. - `auth.svelte.ts` - JWT token for coral-remote-server API. Methods: `setToken()`, `setUsername()`, `clearToken()`. Persisted in electron-store under `access_token` / `username`. -- `settingsStore.svelte.ts` - User settings stored under a single `'settings'` key in electron-store. Exports named key constants (`SSH_PATH`, `URL_VISUALIZER`, `URL_REMOTE_SERVER`, `USE_MPI`) and a `settingsState` object with `getKey(key)` / `setKey(key, value)` methods. +- `settingsStore.svelte.ts` - **Pure config store** — user settings persisted under a single `'settings'` key in electron-store (`AppSettings`: `execution` is just `{ local, remote }` runner-config targets — paths + each target's per-backend-kind `probes` status; `location`/`backendKind` live in `executionSelection`, not here). It never imports the selection store; every selection-dependent member takes `location`/`backendKind` as **explicit arguments**. `settingsState` exposes getters `execution`, `remote`, `local`, `getParametersFileName(location)`, `getProbe(location, kind)`, and async setters `saveExecutionPaths(execution)` (persist edited paths, preserving probe status), `recordProbe(location, kind, status)`, `saveParametersFileName(location, name)`, `saveUrlVisualizer`, `saveUrlRemoteServer`. In `settingsActions.ts`, `probeAndSaveExecution(location, backendKind, execution)` probes (via a focused `ProbeRequest`), routes the returned payload to the per-location registry/params store (`applySyncedMetadata`), and records the status; `warnIfUnvalidated(location, kind)` toasts when a combination has no successful probe. - `currentProjectStore.svelte.ts` - Current project metadata (`id`, `name`). Methods: `set()`, `clear()`, `updateName()`. Exports `ApiProject` interface for use in components. - `jobsStore.svelte.ts` - Slurm job tracking. `jobsState` has `isEmpty`/`oneOrLess` getters, `update()` refreshes from SSH. `jobIdMapState` maps Slurm scheduler IDs → a `JobIdEntry` (`internalId` used in touch-dir paths, `backendKind`, and the per-job `workingDirectory`). The `workingDirectory` is the root for single runs and the per-stage subdirectory for pipeline stages; `getOutFileContent` / `getNodesExecutionStatus` resolve a job's `slurm-.out` and `nodes-exec-status/` from it (via `getJobWorkingDirectory` / `getEntryByInternal`), so logs and node status work uniformly for both. - `toastsStore.svelte.ts` - Toast notifications. Use `toastState.add({ message, type })` to show a toast. Supports `'error'`/`'success'` types with auto-dismissal. - `dndStore.svelte.ts` - Drag-and-drop state (`dndNodeDataState.current`) for tracking which sidebar node is being dragged onto the canvas. -- `viewModeStore.svelte.ts` - Which central editor is shown: `viewModeState.value` is `'graph' | 'pipeline'`, with `toggle()`. Transient (not persisted). `App.svelte` renders `PipelineCanvas` when `'pipeline'`, else the graph/parameters editor. +- `viewModeStore.svelte.ts` - Which central editor is shown: `viewModeState.value` is `'single' | 'pipeline'`, with `toggle()`. Transient (not persisted; resets to `'single'` each launch). `App.svelte` renders `PipelineCanvas` when `'pipeline'`, else the graph/parameters editor. The overlay mode dropdown composes this with `executionSelectionState.backendKind` into `coral | executable | pipeline`. - `pipeline.svelte.ts` - Transient store for the pipeline editor, holding the pipeline as xyflow `Node[]`/`Edge[]` (one node per stage, one edge per ordering dependency). Mirrors the get/set shape of `nodes.svelte.ts` (`getNodes`/`setNodes`/`getEdges`/`setEdges`) for canvas binding, plus `pipelineState` with `addCoralStage()`, `addExecutableStage()`, `updateStageData()`, `updateStageConfig()`, `setStageParameters()`, `removeStage()`, `clear()`, `toPipeline()`, and a `validation` getter. ### Pipeline Orchestration @@ -328,7 +329,7 @@ cd /app && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build - **Svelte 5 runes**: Uses `$state`, `$derived`, `$effect` for reactivity (not legacy stores). Use `$state.snapshot()` when passing reactive state to non-reactive contexts. See Code Conventions for the writable `$derived` pattern. - **Parameter file formats** (`src/lib/utils/parameterFileFormat.ts`): Shared utility (imported by both the Svelte frontend and the Electron main process) for parsing and serialising parameter files in JSON or deal.II `.prm` format. Key exports: `parseParametersFileWithFormat(content, fileName?)` (auto-detects format via content sniffing), `serializeParametersFile(tree, fileName?)` (format derived from the filename extension), `getParameterProbeFileNames(fileName)` (returns JSON candidate first, then PRM). **JSON is always preferred over PRM** when probing an executable — JSON carries rich metadata (validation patterns, default values, documentation) while PRM stores every value as plain text. Format detection uses content sniffing first, filename extension second. - **IPC channels**: `execute-ssh-with-key`, `export-graph-ssh`, `set-theme`, `open-external-url`, `upload-file-ssh`, `store:get`, `store:set`, `store:remove` -- **Electron storage keys** (`electron/utils/storage.ts`): `access_token`, `username`, `settings`, `colorMode` (default: `'light'`), `registered_nodes`, `registered_network_nodes`, `jobs`, `jobIdMap` +- **Electron storage keys** (`electron/utils/storage.ts`): `access_token`, `username`, `settings`, `colorMode` (default: `'light'`), `registered_nodes` (per-location `{ local, remote }`), `registered_network_nodes`, `execution_parameters` (per-location params), `execution_selection` (active `location`/`backendKind`), `jobs`, `jobIdMap` - **Electron TypeScript build**: `electron/**/*.ts` files are compiled by `tsc` (not Vite) to `dist-electron/`. The main process uses `tsconfig.electron.json` (ESM output); the preload uses `tsconfig.electron.preload.json` (CJS output, required by Electron's sandboxed preload context). `dist-electron/` is gitignored. Type declarations for `ssh2` (which ships no `.d.ts`) live in `electron/types/ssh2.d.ts`. - **Git hooks (Husky)**: On commit — `npm run lint` and `npm run check:electron` (failures abort), then `lint-staged` runs Prettier on staged files only and re-stages the formatted result, so formatting changes land in the same commit. - **CI (GitHub Actions)**: All workflows run `npm run check` (svelte-check), `npm run check:electron` (electron tsc), and `npm test`. Workflow files: `.github/workflows/ci.yml`, `.github/workflows/release-linux.yml`, `.github/workflows/release-macos.yml`. @@ -337,9 +338,9 @@ cd /app && cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build - **Auto-layout**: `applyAutoLayout(nodes, edges, direction?)` in `src/lib/utils/autoLayout.ts` repositions nodes using the dagre rank-based algorithm (`'LR'` by default). Call after loading or structurally modifying a graph when node positions aren't provided. - **Registry validation**: `filterValidNodes(registry)` in `src/lib/utils/registryValidator.ts` validates a raw registry payload from CORAL, returning a `[validRegistry, skippedKeys]` tuple. Used when loading the registry at startup to skip malformed entries. - **Key type enums** (`src/lib/types/`): `ConnectionType` (INPUT/OUTPUT/PASSTHROUGH), `ExecNodeStatus` (FAILED/SUCCEEDED/RUNNING), `JobStatus` (COMPLETED/FAILED/PENDING/RUNNING), `nodeColors` (maps node types to CSS colors), `HIDDEN_SIDEBAR_NODE_TYPES` (excludes ABSTRACT and NETWORK from sidebar listing). -- **Slurm batch templates**: Two templates in `src/lib/templates/` — `sbatch.template.sh` (non-MPI) and `sbatch-mpi.template.sh` (MPI via `mpirun --allow-run-as-root -np ${SLURM_NTASKS:-1}`). Imported at build time via Vite's `?raw` suffix. `sshMessages.ts` selects between them based on the `USE_MPI` setting. Both templates expose `{{INTERNAL_JOB_ID}}` (used for `--touch-dir nodes-exec-status/`) and `{{TIME_LIMIT}}`. The MPI template additionally exposes `{{NODES}}` and `{{NTASKS_PER_NODE}}`, filled at runtime from `JobConfig` (defaults: 1 node, 4 tasks/node, 01:00:00 time limit). Clicking Execute always opens `JobConfigModal.svelte` (renamed from `MpiConfigModal.svelte`) — it shows MPI-specific fields (nodes, tasks/node) only when MPI is enabled, and always shows the time limit field, plus an optional run-name field (remote mode) used to name the run's output folder. `PipelineCanvas.svelte`'s "Run pipeline" button opens the analogous `PipelineRunNameModal.svelte` for the same purpose before calling `runPipelineRemote`. +- **Slurm batch templates**: Two templates in `src/lib/templates/` — `sbatch.template.sh` (non-MPI) and `sbatch-mpi.template.sh` (MPI via `mpirun --allow-run-as-root -np ${SLURM_NTASKS:-1}`). Imported at build time via Vite's `?raw` suffix. `sshMessages.ts` selects between them based on the `USE_MPI` setting. Both templates expose `{{INTERNAL_JOB_ID}}` (used for `--touch-dir nodes-exec-status/`) and `{{TIME_LIMIT}}`. The MPI template additionally exposes `{{NODES}}` and `{{NTASKS_PER_NODE}}`, filled at runtime from `JobConfig` (defaults: 1 node, 4 tasks/node, 01:00:00 time limit). The single sidebar Run button (`SidebarButtons.svelte`) is context-aware: in single mode it opens `JobConfigModal.svelte` (renamed from `MpiConfigModal.svelte`) — which shows MPI-specific fields (nodes, tasks/node) only when MPI is enabled, always shows the time limit field, plus an optional run-name field (remote mode) used to name the run's output folder; in pipeline mode it opens `PipelineRunNameModal.svelte` and calls `runPipelineRemote`. The pipeline stage/import-export controls also live in `SidebarButtons` (shown in pipeline mode), so `PipelineCanvas.svelte` is a pure canvas. Execution location/mode are chosen from the interactive `ExecutionBadge.svelte` dropdowns in the top-right overlay (`App.svelte`), not from Settings. - **MPI graph payload**: When MPI is enabled, `buildGraphPayload()` in `sshMessages.ts` injects a `plugin: { MPI: { enabled: true, max_num_threads: 1 } }` block at the top of the exported network JSON, as required by CORAL for MPI initialization. -- **Node execution status**: `getNodesExecutionStatus(jobIdInternal)` reads files from `/app/shared-data/nodes-exec-status//` on the remote server, returning a `Map` of status sequences (e.g. `'running'`, `'succeeded'`, `'failed'`). +- **Node execution status**: `getNodesExecutionStatus(location, jobIdInternal)` reads files from `/app/shared-data/nodes-exec-status//` on the remote server (or the local run dir), returning a `Map` of status sequences (e.g. `'running'`, `'succeeded'`, `'failed'`). Like `getOutFileContent(location, jobId)`, it takes the execution location explicitly (callers pass `executionSelectionState.location`). ## Git Workflow diff --git a/electron/ipcHandlers.ts b/electron/ipcHandlers.ts index 78aeb23..38067b6 100644 --- a/electron/ipcHandlers.ts +++ b/electron/ipcHandlers.ts @@ -3,7 +3,7 @@ import { dialog } from 'electron' import fs from 'fs' import type { AppSettings, - ExecutionSettings, + ProbeRequest, } from '../src/lib/types/settingsTypes.js' import { @@ -58,8 +58,8 @@ export function registerIpcHandlers(): void { ipcMain.handle( 'probe-sync-execution-settings', - async (_event, executionSettings: ExecutionSettings) => { - return await probeAndSyncExecutionSettings(executionSettings) + async (_event, request: ProbeRequest) => { + return await probeAndSyncExecutionSettings(request) } ) diff --git a/electron/utils/executionProbe.ts b/electron/utils/executionProbe.ts index 71aea21..45bf06f 100644 --- a/electron/utils/executionProbe.ts +++ b/electron/utils/executionProbe.ts @@ -1,6 +1,9 @@ import type { - ExecutionSettings, - ProbeResult, + ProbeRequest, + ProbeResponse, + ExecutionTargetSettings, + RemoteExecutionSettings, + BackendKind, NodeRegistryMetadata, ParametersTemplateMetadata, } from '../../src/lib/types/settingsTypes.js' @@ -24,7 +27,7 @@ const shellEscape = (value: string | number) => { } const getExecutableParametersFileName = ( - target: ExecutionSettings['local'] | ExecutionSettings['remote'] + target: ExecutionTargetSettings ): string => { return target.parametersFileName || 'parameters.json' } @@ -45,20 +48,24 @@ const formatErrorMessage = (error: unknown): string => { return (error as Error)?.message || String(error) } -const validateExecutionSettings = (execution: ExecutionSettings): void => { - const target = execution?.[execution.location] +const validateExecutionSettings = ({ + location, + backendKind, + target, +}: ProbeRequest): void => { if (!target) { throw new Error('Invalid execution target configuration') } - if (execution.location === 'remote') { - if (!execution.remote.host || !execution.remote.username) { + if (location === 'remote') { + const remote = target as RemoteExecutionSettings + if (!remote.host || !remote.username) { throw new Error('Remote configuration requires host and username') } - if (!execution.remote.sshKeyPath) { + if (!remote.sshKeyPath) { throw new Error('Remote configuration requires an SSH private key') } - if (!fs.existsSync(execution.remote.sshKeyPath)) { + if (!fs.existsSync(remote.sshKeyPath)) { throw new Error('Configured SSH private key path does not exist') } } @@ -67,14 +74,14 @@ const validateExecutionSettings = (execution: ExecutionSettings): void => { throw new Error('A working directory is required') } - if (execution.backendKind === 'coral' && !target.coralBinaryPath) { + if (backendKind === 'coral' && !target.coralBinaryPath) { throw new Error('Coral backend requires a binary path') } - if (execution.backendKind === 'coral' && !target.coralPluginPath) { + if (backendKind === 'coral' && !target.coralPluginPath) { throw new Error('Coral backend requires a plugin path') } - if (execution.backendKind === 'executable' && !target.executablePath) { + if (backendKind === 'executable' && !target.executablePath) { throw new Error('Executable backend requires an executable path') } } @@ -86,36 +93,36 @@ const fileMustExist = (filePath: string | undefined, label: string): void => { } } -const probeLocalPaths = (execution: ExecutionSettings): void => { - if (execution.backendKind === 'coral') { - fileMustExist(execution.local.coralBinaryPath, 'Coral binary') - } else if (execution.backendKind === 'executable') { - fileMustExist(execution.local.executablePath, 'Executable') +const probeLocalPaths = ( + target: ExecutionTargetSettings, + backendKind: BackendKind +): void => { + if (backendKind === 'coral') { + fileMustExist(target.coralBinaryPath, 'Coral binary') + } else if (backendKind === 'executable') { + fileMustExist(target.executablePath, 'Executable') } - if (!fs.existsSync(execution.local.workingDirectory)) { + if (!fs.existsSync(target.workingDirectory)) { throw new Error( - `Working directory does not exist: ${execution.local.workingDirectory}` + `Working directory does not exist: ${target.workingDirectory}` ) } } /** - * @param execution + * @param target - The local execution target. * @returns Registry metadata from the local coral binary. */ const getCoralRegistryMetadataLocal = async ( - execution: ExecutionSettings + target: ExecutionTargetSettings ): Promise => { - const registryPath = path.join( - execution.local.workingDirectory, - 'node_types.json' - ) + const registryPath = path.join(target.workingDirectory, 'node_types.json') await execFileAsync( - execution.local.coralBinaryPath, - ['-p', execution.local.coralPluginPath, 'register'], + target.coralBinaryPath, + ['-p', target.coralPluginPath, 'register'], { - cwd: execution.local.workingDirectory, + cwd: target.workingDirectory, } ) @@ -127,17 +134,17 @@ const getCoralRegistryMetadataLocal = async ( } /** - * @param execution + * @param target - The local execution target. * @returns Parameters template metadata from the local executable. */ const getExecutableTemplateMetadataLocal = async ( - execution: ExecutionSettings + target: ExecutionTargetSettings ): Promise => { const tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'dealiix-exec-probe-') ) const candidates = getParameterProbeFileNames( - getExecutableParametersFileName(execution.local) + getExecutableParametersFileName(target) ) let lastError: unknown = null @@ -148,8 +155,8 @@ const getExecutableTemplateMetadataLocal = async ( await fs.promises.mkdir(path.dirname(paramsPath), { recursive: true }) try { - await execFileAsync(execution.local.executablePath, [paramsPath], { - cwd: execution.local.workingDirectory, + await execFileAsync(target.executablePath, [paramsPath], { + cwd: target.workingDirectory, }) } catch (error) { if (!fs.existsSync(paramsPath)) { @@ -173,30 +180,30 @@ const getExecutableTemplateMetadataLocal = async ( } /** - * @param execution + * @param target - The remote execution target. * @returns Registry metadata fetched from the remote coral binary via SSH. */ const getCoralRegistryMetadataRemote = async ( - execution: ExecutionSettings + target: RemoteExecutionSettings ): Promise => { const sshConfig = { - host: execution.remote.host, - port: execution.remote.port, - username: execution.remote.username, - pathToSsh: execution.remote.sshKeyPath, + host: target.host, + port: target.port, + username: target.username, + pathToSsh: target.sshKeyPath, } // Run coral register — stdout suppressed so the registry file is the only output later; // stderr flows through SSH so errors surface in the rejection message. await connectToSSHWithKey( - `cd ${shellEscape(execution.remote.workingDirectory)} && ${shellEscape(execution.remote.coralBinaryPath)} -p ${shellEscape(execution.remote.coralPluginPath)} register > /dev/null`, + `cd ${shellEscape(target.workingDirectory)} && ${shellEscape(target.coralBinaryPath)} -p ${shellEscape(target.coralPluginPath)} register > /dev/null`, sshConfig, { rejectOnNonZeroCode: true } ) // Read the generated registry file in a separate call so its content is unambiguous. const registryRaw = await connectToSSHWithKey( - `cd ${shellEscape(execution.remote.workingDirectory)} && cat node_types.json`, + `cd ${shellEscape(target.workingDirectory)} && cat node_types.json`, sshConfig, { rejectOnNonZeroCode: true } ) @@ -208,31 +215,31 @@ const getCoralRegistryMetadataRemote = async ( } /** - * @param execution + * @param target - The remote execution target. * @returns Parameters template metadata fetched from the remote executable via SSH. */ const getExecutableTemplateMetadataRemote = async ( - execution: ExecutionSettings + target: RemoteExecutionSettings ): Promise => { const sshConfig = { - host: execution.remote.host, - port: execution.remote.port, - username: execution.remote.username, - pathToSsh: execution.remote.sshKeyPath, + host: target.host, + port: target.port, + username: target.username, + pathToSsh: target.sshKeyPath, } const candidates = getParameterProbeFileNames( - getExecutableParametersFileName(execution.remote) + getExecutableParametersFileName(target) ) let lastError: unknown = null for (const paramsFile of candidates) { const probeFile = `dealiix-probe-${Date.now()}-${paramsFile}` - const probePath = `${execution.remote.workingDirectory}/${probeFile}` + const probePath = `${target.workingDirectory}/${probeFile}` try { // Clean up any leftover probe file from a previous failed run. await connectToSSHWithKey( - `cd ${shellEscape(execution.remote.workingDirectory)} && rm -f ${shellEscape(probeFile)}`, + `cd ${shellEscape(target.workingDirectory)} && rm -f ${shellEscape(probeFile)}`, sshConfig, { rejectOnNonZeroCode: true } ) @@ -240,7 +247,7 @@ const getExecutableTemplateMetadataRemote = async ( // Run the executable — tolerate non-zero exit because some executables write the params // file but still exit with a non-zero code (e.g. usage errors when no other args are given). await connectToSSHWithKey( - `cd ${shellEscape(execution.remote.workingDirectory)} && ${shellEscape(execution.remote.executablePath)} ${shellEscape(probeFile)}`, + `cd ${shellEscape(target.workingDirectory)} && ${shellEscape(target.executablePath)} ${shellEscape(probeFile)}`, sshConfig ) @@ -268,45 +275,56 @@ const getExecutableTemplateMetadataRemote = async ( } /** - * Validates execution settings, probes local paths or remote connectivity, and fetches + * Validates one execution target, probes local paths or remote connectivity, and fetches * backend metadata (node registry or parameters template) depending on the backend kind. * - * @param execution - The execution settings to validate and probe. + * @param request - The focused probe request (location, backend kind, and the one target). * @returns Probe result with metadata and sync timestamp, or an error result on failure. */ export const probeAndSyncExecutionSettings = async ( - execution: ExecutionSettings -): Promise => { + request: ProbeRequest +): Promise => { try { - validateExecutionSettings(execution) + validateExecutionSettings(request) + + const { location, backendKind, target } = request - if (execution.location === 'local') { - probeLocalPaths(execution) + if (location === 'local') { + probeLocalPaths(target, backendKind) } let metadata = null - if (execution.backendKind === 'coral') { + if (backendKind === 'coral') { metadata = - execution.location === 'local' - ? await getCoralRegistryMetadataLocal(execution) - : await getCoralRegistryMetadataRemote(execution) - } else if (execution.backendKind === 'executable') { + location === 'local' + ? await getCoralRegistryMetadataLocal(target) + : await getCoralRegistryMetadataRemote( + target as RemoteExecutionSettings + ) + } else if (backendKind === 'executable') { metadata = - execution.location === 'local' - ? await getExecutableTemplateMetadataLocal(execution) - : await getExecutableTemplateMetadataRemote(execution) + location === 'local' + ? await getExecutableTemplateMetadataLocal(target) + : await getExecutableTemplateMetadataRemote( + target as RemoteExecutionSettings + ) } return { - ok: true, - message: 'Configuration validated successfully', + status: { + ok: true, + message: 'Configuration validated successfully', + syncedAt: new Date().toISOString(), + }, metadata, - syncedAt: new Date().toISOString(), } } catch (error) { return { - ok: false, - message: (error as Error)?.message || 'Configuration probe failed', + status: { + ok: false, + message: (error as Error)?.message || 'Configuration probe failed', + }, + metadata: null, } } } diff --git a/src/App.svelte b/src/App.svelte index eacb9a4..bab4803 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,14 +11,22 @@ import { sideBarState } from './lib/stores/sidebar.svelte' import ButtonToggleMenu from './lib/components/layout/ButtonToggleMenu.svelte' import ToastsWrapper from './lib/components/ToastsWrapper.svelte' - import { settingsState } from './lib/stores/settingsStore.svelte' + import { executionSelectionState } from './lib/stores/executionSelection.svelte' import { graphHistoryState } from './lib/stores/graphStack.svelte' import { viewModeState } from './lib/stores/viewModeStore.svelte' + import { setActiveLocation as setRegistryLocation } from './lib/stores/registryStore.svelte' + import { setActiveLocation as setParamsLocation } from './lib/stores/parametersStore.svelte' - let isCoralMode = $derived(settingsState.isCoralMode) + let isCoralMode = $derived(executionSelectionState.isCoralMode) let viewMode = $derived(viewModeState.value) - let executionLocation = $derived(settingsState.execution.location) - let backendKind = $derived(settingsState.execution.backendKind) + + // Keep the per-location registry/params stores pointed at the active location + // (covers the async load and any location switch). + $effect(() => { + const location = executionSelectionState.location + setRegistryLocation(location) + setParamsLocation(location) + }) // Undo: Ctrl/Cmd+Z — Redo: Ctrl/Cmd+Shift+Z or Ctrl+Y // Skip when focus is inside a text field so normal editing shortcuts are unaffected. @@ -84,17 +92,8 @@
- - +
{#if viewMode === 'pipeline'} @@ -181,19 +180,4 @@ height: 100%; position: relative; } - - .view-toggle { - background: var(--primary-color); - color: var(--ternary-color); - border: 1px solid var(--ternary-color); - border-radius: 8px; - padding: 0.4rem 0.7rem; - font-size: 0.8rem; - font-weight: bold; - cursor: pointer; - } - - .view-toggle:hover { - border-color: var(--border-color-hover); - } diff --git a/src/lib/components/JobConfigModal.svelte b/src/lib/components/JobConfigModal.svelte index 7922bf8..8a50285 100644 --- a/src/lib/components/JobConfigModal.svelte +++ b/src/lib/components/JobConfigModal.svelte @@ -12,6 +12,7 @@ import { getNodesSnapshot, getEdgesSnapshot } from '../stores/nodes.svelte' import { parametersState } from '../stores/parametersStore.svelte' import { settingsState } from '../stores/settingsStore.svelte' + import { executionSelectionState } from '../stores/executionSelection.svelte' import { toastState } from '../stores/toastsStore.svelte' import { isValidSlurmTime, SLURM_TIME_HINT } from '../utils/slurmTime' @@ -26,15 +27,16 @@ let timeLimit = $state('01:00:00') let useMpi = $state(false) let runName = $state('') + let location = $derived(executionSelectionState.location) // Writable derived: pre-filled from settings, temporarily overridable per run. // Resets to the stored value automatically if settings change. - let parametersFileName = $derived(settingsState.activeParametersFileName) - let hasParameters = $derived(parametersState.value !== null) - let isExecutableMode = $derived(settingsState.isExecutableMode) - let isCoralMode = $derived(settingsState.isCoralMode) - let isRemoteExecution = $derived( - settingsState.execution.location === 'remote' + let parametersFileName = $derived( + settingsState.getParametersFileName(location) ) + let hasParameters = $derived(parametersState.value !== null) + let isExecutableMode = $derived(executionSelectionState.isExecutableMode) + let isCoralMode = $derived(executionSelectionState.isCoralMode) + let isRemoteExecution = $derived(location === 'remote') let totalProcesses = $derived(nodes * tasksPerNode) let timeLimitError = $derived( @@ -42,6 +44,17 @@ ) const handleConfirm = () => { + // Block runs for a mode that was never validated — switching mode no longer + // probes, so the paths/payload for this combination may be unset. + const backendKind = executionSelectionState.backendKind + if (!settingsState.getProbe(location, backendKind)?.ok) { + toastState.add({ + message: `Configuration for ${location}/${backendKind} is not validated — open Settings and Validate & Sync.`, + type: 'error', + }) + return + } + getModal(modalId)?.close() // Settings are read once, at this UI boundary, and flow in as explicit args. @@ -56,6 +69,7 @@ const run = isExecutableMode ? exportAndEvalExecutable( + location, { executablePath: target.executablePath, parametersFileName, @@ -63,6 +77,7 @@ trimmedRunName ) : exportAndEvalCoralGraph( + location, getNodesSnapshot(), getEdgesSnapshot(), { diff --git a/src/lib/components/ParametersView.svelte b/src/lib/components/ParametersView.svelte index 4162645..b6224e7 100644 --- a/src/lib/components/ParametersView.svelte +++ b/src/lib/components/ParametersView.svelte @@ -3,6 +3,7 @@ import Modal, { getModal } from './layout/Modal.svelte' import { parametersState } from '../stores/parametersStore.svelte' import { settingsState } from '../stores/settingsStore.svelte' + import { executionSelectionState } from '../stores/executionSelection.svelte' import { toastState } from '../stores/toastsStore.svelte' import type { ParameterLeaf, @@ -394,7 +395,7 @@ if (!snapshot) return const defaultPath = lastParametersFilePath || - settingsState.activeParametersFileName || + settingsState.getParametersFileName(executionSelectionState.location) || 'template_parameters.json' if (window.electron?.invoke) { diff --git a/src/lib/components/PipelineCanvas.svelte b/src/lib/components/PipelineCanvas.svelte index f2a419a..ecc7534 100644 --- a/src/lib/components/PipelineCanvas.svelte +++ b/src/lib/components/PipelineCanvas.svelte @@ -2,11 +2,10 @@ import { SvelteFlow, Background, - Controls, - Panel, type NodeTypes, type Connection, type Edge, + MiniMap, } from '@xyflow/svelte' import '@xyflow/svelte/dist/base.css' import { @@ -17,22 +16,8 @@ getEdgesSnapshot, pipelineState, } from '../stores/pipeline.svelte' - import { buildExportMeta } from '../utils/exportMeta' - import { - getNodesSnapshot as getGraphNodesSnapshot, - getEdgesSnapshot as getGraphEdgesSnapshot, - } from '../stores/nodes.svelte' - import { buildGraphPayload } from '../utils/sshMessages' - import { runPipelineRemote } from '../orchestration/pipelineOrchestrator' import { resolveExecutionOrder } from '../orchestration/executionOrder' - import { settingsState } from '../stores/settingsStore.svelte' import { colorModeState } from '../stores/colorModeStore.svelte' - import { toastState } from '../stores/toastsStore.svelte' - import { jobsState } from '../stores/jobsStore.svelte' - import { currentProjectState } from '../stores/currentProjectStore.svelte' - import Button from './layout/Button.svelte' - import { getModal } from './layout/Modal.svelte' - import PipelineRunNameModal from './PipelineRunNameModal.svelte' import CoralStageNode from './nodes/CoralStageNode.svelte' import ExecutableStageNode from './nodes/ExecutableStageNode.svelte' @@ -41,14 +26,6 @@ executableStage: ExecutableStageNode as unknown as NodeTypes[string], } - const RUN_NAME_MODAL_ID = 'pipeline-run-name-modal' - - let coralGraphInput: HTMLInputElement | undefined = $state() - let pipelineImportInput: HTMLInputElement | undefined = $state() - - let isRemote = $derived(settingsState.execution.location === 'remote') - let validation = $derived(pipelineState.validation) - /** * Rejects connections that would create a self-loop, duplicate an existing edge, * or introduce a cycle (which would make the pipeline unschedulable). @@ -56,7 +33,6 @@ */ const isValidConnection = (connection: Connection | Edge): boolean => { const { source, target } = connection - // console.log('[isValidConnection]', { source, target }) if (!source || !target || source === target) { console.warn( `Source stage ${source} and target stage ${target} are invalid: self-loop or missing endpoint` @@ -87,151 +63,6 @@ return false } } - - const handleAddCoralFromFile = async () => { - const file = coralGraphInput?.files?.[0] - if (!file) return - try { - const graph = JSON.parse(await file.text()) - if (!graph || typeof graph !== 'object' || !('workflow' in graph)) { - toastState.add({ - message: - 'This file does not look like a CORAL graph (no "workflow").', - type: 'error', - }) - return - } - const name = file.name.replace(/\.json$/i, '') - // Capture the coral install paths at stage creation (symmetric to the - // executable path capture) so the stage is a self-contained execution - // request — the submit primitive no longer reads settingsState. - pipelineState.addCoralStage({ - name, - graph, - coralBinaryPath: settingsState.remote.coralBinaryPath, - coralPluginPath: settingsState.remote.coralPluginPath, - }) - } catch (error) { - toastState.add({ - message: - error instanceof Error ? error.message : 'Failed to read graph file', - type: 'error', - }) - } finally { - if (coralGraphInput) coralGraphInput.value = '' - } - } - - /** - * Downloads the current pipeline as a JSON file: `toPipeline()` wrapped under - * `pipeline` plus a metadata envelope stamped now, so positions and per-stage - * configs round-trip exactly on re-import. - */ - const handleExportPipeline = () => { - const payload = { - pipeline: pipelineState.toPipeline(), - ...buildExportMeta(), - } - const jsonString = JSON.stringify(payload, null, 2) - const blob = new Blob([jsonString], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = `pipeline-${Date.now()}.json` - document.body.appendChild(anchor) - anchor.click() - document.body.removeChild(anchor) - URL.revokeObjectURL(url) - } - - /** Replaces the canvas with a previously exported pipeline file. */ - const handleImportPipeline = async () => { - const file = pipelineImportInput?.files?.[0] - if (!file) return - try { - const parsed = JSON.parse(await file.text()) - if ( - !Array.isArray(parsed?.pipeline?.nodes) || - !Array.isArray(parsed?.pipeline?.edges) - ) { - toastState.add({ - message: - 'This file does not look like a pipeline export (no pipeline.nodes/edges array).', - type: 'error', - }) - return - } - pipelineState.load(parsed) - } catch (error) { - toastState.add({ - message: - error instanceof Error - ? error.message - : 'Failed to read pipeline file', - type: 'error', - }) - } finally { - if (pipelineImportInput) pipelineImportInput.value = '' - } - } - - const handleAddCoralFromCanvas = () => { - const graph = buildGraphPayload( - getGraphNodesSnapshot(), - getGraphEdgesSnapshot(), - false - ) - pipelineState.addCoralStage({ - name: currentProjectState.name || 'canvas graph', - graph, - coralBinaryPath: settingsState.remote.coralBinaryPath, - coralPluginPath: settingsState.remote.coralPluginPath, - }) - } - - const handleAddExecutable = () => { - pipelineState.addExecutableStage({ - name: 'executable', - executablePath: settingsState.remote.executablePath, - parametersFileName: - settingsState.remote.parametersFileName || 'parameters.json', - }) - } - - const handleOpenRunModal = () => { - if (!validation.runnable) { - toastState.add({ - message: `Cannot run: ${validation.issues.join('; ')}`, - type: 'error', - }) - return - } - getModal(RUN_NAME_MODAL_ID)?.open() - } - - const handleRun = (name: string) => { - const pipeline = pipelineState.toPipeline() - - // Expose the pipeline snapshot for inspection. - console.log('[pipeline canvas]', JSON.parse(JSON.stringify(pipeline))) - - // Toasts and jobsState.update() are side effects of the caller, reported - // through the progress callbacks. - runPipelineRemote(pipeline, name || undefined, (event) => { - if (event.type === 'info' || event.type === 'success') { - toastState.add({ message: event.message, type: event.type }) - } else if (event.type === 'error') { - toastState.add({ message: event.message, type: 'error' }) - } else if (event.type === 'stageTerminal') { - jobsState.update() - } - }).catch((error) => { - toastState.add({ - message: error instanceof Error ? error.message : 'Pipeline run failed', - type: 'error', - }) - }) - }
@@ -243,101 +74,14 @@ colorMode={colorModeState.value} fitView > + - - -
- {#if !isRemote} - - {/if} -
- - - -
-
- - - -
- {#if validation.issues.length > 0} -
    - {#each validation.issues as issue (issue)} -
  • {issue}
  • - {/each} -
- {/if} -
-
- - -
- - diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index ace4dcf..df10730 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -1,11 +1,14 @@ + + + + + diff --git a/src/lib/components/layout/ExecutionBadge.svelte b/src/lib/components/layout/ExecutionBadge.svelte index 3b95d94..863528e 100644 --- a/src/lib/components/layout/ExecutionBadge.svelte +++ b/src/lib/components/layout/ExecutionBadge.svelte @@ -1,26 +1,74 @@
- {location} - · - {modeLabel} + + +
diff --git a/src/lib/components/layout/JobsTable.svelte b/src/lib/components/layout/JobsTable.svelte index e833dd9..1e225cc 100644 --- a/src/lib/components/layout/JobsTable.svelte +++ b/src/lib/components/layout/JobsTable.svelte @@ -10,6 +10,7 @@ JOB_LIST_DAYS, } from '../../utils/sshMessages' import { normalizeJobStatus, isTerminalStatus } from '../../types/jobTypes' + import { executionSelectionState } from '../../stores/executionSelection.svelte' import RefreshIcon from '../icons/RefreshIcon.svelte' import Button from './Button.svelte' import { toastState } from '../../stores/toastsStore.svelte' @@ -66,7 +67,10 @@ const handleLogClick = async (jobId: string) => { try { currentJobId = jobId - outLogText = await getOutFileContent(jobId) + outLogText = await getOutFileContent( + executionSelectionState.location, + jobId + ) getModal(outLogModalId)?.open() } catch (error) { toastState.add({ @@ -87,7 +91,10 @@ console.log( `Getting nodes execution status for Slurm job Id ${jobIdSlurm}, internal job Id ${jobIdInternal}` ) - const result = await getNodesExecutionStatus(jobIdInternal) + const result = await getNodesExecutionStatus( + executionSelectionState.location, + jobIdInternal + ) nodeStatusMap = result currentStatusJobId = jobIdSlurm currentJobIdInternal = jobIdInternal diff --git a/src/lib/components/layout/NodeStatusModal.svelte b/src/lib/components/layout/NodeStatusModal.svelte index f8aa9c1..5e9f8b6 100644 --- a/src/lib/components/layout/NodeStatusModal.svelte +++ b/src/lib/components/layout/NodeStatusModal.svelte @@ -4,6 +4,7 @@ import SuccessIcon from '../icons/SuccessIcon.svelte' import ErrorIcon from '../icons/ErrorIcon.svelte' import { getNodesExecutionStatus } from '../../utils/sshMessages' + import { executionSelectionState } from '../../stores/executionSelection.svelte' import { ExecNodeStatus } from '../../types/jobTypes' interface Props { @@ -96,7 +97,10 @@ const interval = setInterval(async () => { try { - const result = await getNodesExecutionStatus(jobIdInternal) + const result = await getNodesExecutionStatus( + executionSelectionState.location, + jobIdInternal + ) console.log( `Polling for internal jobId ${jobIdInternal}`, $state.snapshot(result) diff --git a/src/lib/components/layout/SidebarButtons.svelte b/src/lib/components/layout/SidebarButtons.svelte index c67d397..962eefb 100644 --- a/src/lib/components/layout/SidebarButtons.svelte +++ b/src/lib/components/layout/SidebarButtons.svelte @@ -6,9 +6,14 @@ getNodesSnapshot, setNodes, } from '../../stores/nodes.svelte' - import { setRegistry } from '../../stores/registryStore.svelte' + import { mergeRegistry } from '../../stores/registryStore.svelte' import { importGraphFromProtocol } from '../../utils/graphParser' import { buildGraphPayload, openNewWindow } from '../../utils/sshMessages' + import { pipelineState } from '../../stores/pipeline.svelte' + import { runPipelineRemote } from '../../orchestration/pipelineOrchestrator' + import { buildExportMeta } from '../../utils/exportMeta' + import { jobsState } from '../../stores/jobsStore.svelte' + import PipelineRunNameModal from '../PipelineRunNameModal.svelte' import Modal, { getModal } from './Modal.svelte' import LoginForm from '../LoginForm.svelte' import SaveProjectForm from '../SaveProjectForm.svelte' @@ -22,6 +27,7 @@ import LoginIcon from '../icons/LoginIcon.svelte' import CubeIcon from '../icons/CubeIcon.svelte' import { settingsState } from '../../stores/settingsStore.svelte' + import { executionSelectionState } from '../../stores/executionSelection.svelte' import { currentProjectState } from '../../stores/currentProjectStore.svelte' import { viewModeState } from '../../stores/viewModeStore.svelte' import { parseGraphWithQualifiedIds } from '../../utils/graphParser' @@ -33,6 +39,7 @@ import JobConfigModal from '../JobConfigModal.svelte' import { graphStackState } from '../../stores/graphStack.svelte' import GridIcon from '../icons/GridIcon.svelte' + import PlusIcon from '../icons/PlusIcon.svelte' import { useSvelteFlow } from '@xyflow/svelte' const { fitView } = useSvelteFlow() @@ -44,10 +51,14 @@ const saveProjectModalId = 'save-project-modal' const JobConfigModalId = 'job-config-modal' const subnetworkWarningModalId = 'subnetwork-warning-modal' - let isCoralMode = $derived(settingsState.isCoralMode) + const runNameModalId = 'pipeline-run-name-modal' + let isCoralMode = $derived(executionSelectionState.isCoralMode) let hasRemoteServer = $derived(settingsState.hasRemoteServer) let hasVisualizer = $derived(settingsState.hasVisualizer) let isSingleMode = $derived(viewModeState.value === 'single') + let isPipelineMode = $derived(viewModeState.value === 'pipeline') + let isRemote = $derived(executionSelectionState.location === 'remote') + let pipelineValidation = $derived(pipelineState.validation) const token = $derived(auth.token) const username = $derived(auth.username) @@ -60,6 +71,8 @@ let importGraphInput: HTMLInputElement | undefined = $state() let importNodesInput: HTMLInputElement | undefined = $state() + let coralGraphInput: HTMLInputElement | undefined = $state() + let pipelineImportInput: HTMLInputElement | undefined = $state() const handleLoginLogout = () => { if (token) { @@ -74,10 +87,161 @@ toastState.add({ message: 'Logged out', type: 'success' }) } + /** + * Unified Run entry point. In single mode it opens the job-config modal; in + * pipeline mode it validates and opens the run-name modal. + */ const handleExecution = () => { + if (isPipelineMode) { + if (!isRemote) { + toastState.add({ + message: 'Pipelines run in remote mode only — switch location first.', + type: 'error', + }) + return + } + if (!pipelineValidation.runnable) { + toastState.add({ + message: `Cannot run: ${pipelineValidation.issues.join('; ')}`, + type: 'error', + }) + return + } + getModal(runNameModalId)?.open() + return + } getModal(JobConfigModalId)?.open() } + const handleAddCoralFromFile = async () => { + const file = coralGraphInput?.files?.[0] + if (!file) return + try { + const graph = JSON.parse(await file.text()) + if (!graph || typeof graph !== 'object' || !('workflow' in graph)) { + toastState.add({ + message: + 'This file does not look like a CORAL graph (no "workflow").', + type: 'error', + }) + return + } + const name = file.name.replace(/\.json$/i, '') + // Capture the coral install paths at stage creation so the stage is a + // self-contained execution request — the submit primitive no longer reads + // settingsState. + pipelineState.addCoralStage({ + name, + graph, + coralBinaryPath: settingsState.remote.coralBinaryPath, + coralPluginPath: settingsState.remote.coralPluginPath, + }) + } catch (error) { + toastState.add({ + message: + error instanceof Error ? error.message : 'Failed to read graph file', + type: 'error', + }) + } finally { + if (coralGraphInput) coralGraphInput.value = '' + } + } + + const handleAddCoralFromCanvas = () => { + const graph = buildGraphPayload( + getNodesSnapshot(), + getEdgesSnapshot(), + false + ) + pipelineState.addCoralStage({ + name: currentProjectState.name || 'canvas graph', + graph, + coralBinaryPath: settingsState.remote.coralBinaryPath, + coralPluginPath: settingsState.remote.coralPluginPath, + }) + } + + const handleAddExecutable = () => { + pipelineState.addExecutableStage({ + name: 'executable', + executablePath: settingsState.remote.executablePath, + parametersFileName: + settingsState.remote.parametersFileName || 'parameters.json', + }) + } + + /** + * Downloads the current pipeline as a JSON file: `toPipeline()` wrapped under + * `pipeline` plus a metadata envelope, so positions and per-stage configs + * round-trip exactly on re-import. + */ + const handleExportPipeline = () => { + const payload = { + pipeline: pipelineState.toPipeline(), + ...buildExportMeta(), + } + const jsonString = JSON.stringify(payload, null, 2) + const blob = new Blob([jsonString], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `pipeline-${Date.now()}.json` + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + } + + /** Replaces the canvas with a previously exported pipeline file. */ + const handleImportPipeline = async () => { + const file = pipelineImportInput?.files?.[0] + if (!file) return + try { + const parsed = JSON.parse(await file.text()) + if ( + !Array.isArray(parsed?.pipeline?.nodes) || + !Array.isArray(parsed?.pipeline?.edges) + ) { + toastState.add({ + message: + 'This file does not look like a pipeline export (no pipeline.nodes/edges array).', + type: 'error', + }) + return + } + pipelineState.load(parsed) + } catch (error) { + toastState.add({ + message: + error instanceof Error + ? error.message + : 'Failed to read pipeline file', + type: 'error', + }) + } finally { + if (pipelineImportInput) pipelineImportInput.value = '' + } + } + + /** Runs the pipeline remotely, surfacing progress events as toasts. */ + const handleRunPipeline = (name: string) => { + const pipeline = pipelineState.toPipeline() + runPipelineRemote(pipeline, name || undefined, (event) => { + if (event.type === 'info' || event.type === 'success') { + toastState.add({ message: event.message, type: event.type }) + } else if (event.type === 'error') { + toastState.add({ message: event.message, type: 'error' }) + } else if (event.type === 'stageTerminal') { + jobsState.update() + } + }).catch((error) => { + toastState.add({ + message: error instanceof Error ? error.message : 'Pipeline run failed', + type: 'error', + }) + }) + } + /** * Loads a graph from a file removing the edges that have type mismatches */ @@ -131,7 +295,12 @@ }) return } - const skippedKeys = await setRegistry(importedNodes) + // Merge into the active location's registry (imported keys win) rather than + // replacing it, so nodes added by an earlier import survive. + const skippedKeys = await mergeRegistry( + importedNodes, + executionSelectionState.location + ) skippedKeys.forEach((key) => { toastState.add({ message: `Registry key '${key}' is not a valid node and was skipped`, @@ -348,26 +517,82 @@ {/if} - - {#if isSingleMode} -
- - - Run -
+ +
+ + + Run +
+ + + + + + {#if isPipelineMode} + + {#snippet icon()} + + {/snippet} + {#snippet items()} + coralGraphInput?.click()} + /> + + + {/snippet} + - + + {#snippet icon()} + + {/snippet} + {#snippet items()} + pipelineImportInput?.click()} + /> + + {/snippet} + + + + {/if} diff --git a/src/lib/stores/executionSelection.svelte.test.ts b/src/lib/stores/executionSelection.svelte.test.ts new file mode 100644 index 0000000..1ad801a --- /dev/null +++ b/src/lib/stores/executionSelection.svelte.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' +import { executionSelectionState } from './executionSelection.svelte' + +describe('executionSelectionState', () => { + it('defaults to remote/coral and derives the mode flags', () => { + expect(executionSelectionState.location).toBe('remote') + expect(executionSelectionState.backendKind).toBe('coral') + expect(executionSelectionState.isCoralMode).toBe(true) + expect(executionSelectionState.isExecutableMode).toBe(false) + }) + + it('updates location and backend kind independently', async () => { + await executionSelectionState.setLocation('local') + await executionSelectionState.setBackendKind('executable') + + expect(executionSelectionState.location).toBe('local') + expect(executionSelectionState.backendKind).toBe('executable') + expect(executionSelectionState.isExecutableMode).toBe(true) + expect(executionSelectionState.isCoralMode).toBe(false) + }) +}) diff --git a/src/lib/stores/executionSelection.svelte.ts b/src/lib/stores/executionSelection.svelte.ts new file mode 100644 index 0000000..b0e1bd0 --- /dev/null +++ b/src/lib/stores/executionSelection.svelte.ts @@ -0,0 +1,56 @@ +import type { ExecutionLocation, BackendKind } from '../types/settingsTypes' + +/** + * Active execution selection — which target (`local`/`remote`) and backend kind + * (`coral`/`executable`) the UI is pointed at. This is pure renderer selection + * state (Electron never reads it), kept separate from the persisted runner + * config in `settingsStore`. Persisted so the app reopens in the same mode. + */ +let location: ExecutionLocation = $state('remote') +let backendKind: BackendKind = $state('coral') + +const SELECTION_KEY = 'execution_selection' + +const persist = async () => { + if (globalThis.window?.electron?.store) { + await window.electron.store.set(SELECTION_KEY, { location, backendKind }) + } +} + +// Load the persisted selection; fall back to the defaults above when absent. +const loadSelection = async () => { + if (!globalThis.window?.electron?.store) return + const stored = await window.electron.store.get(SELECTION_KEY, undefined) + if (stored && typeof stored === 'object') { + const s = stored as Partial<{ + location: ExecutionLocation + backendKind: BackendKind + }> + if (s.location) location = s.location + if (s.backendKind) backendKind = s.backendKind + } +} +loadSelection() + +export const executionSelectionState = { + get location() { + return location + }, + get backendKind() { + return backendKind + }, + get isCoralMode() { + return backendKind === 'coral' + }, + get isExecutableMode() { + return backendKind === 'executable' + }, + async setLocation(next: ExecutionLocation) { + location = next + await persist() + }, + async setBackendKind(next: BackendKind) { + backendKind = next + await persist() + }, +} diff --git a/src/lib/stores/jobsStore.svelte.ts b/src/lib/stores/jobsStore.svelte.ts index 7bd8345..1937c81 100644 --- a/src/lib/stores/jobsStore.svelte.ts +++ b/src/lib/stores/jobsStore.svelte.ts @@ -1,5 +1,5 @@ import { fetchRemoteJobs, JOB_LIST_DAYS } from '../utils/sshMessages' -import { settingsState } from './settingsStore.svelte' +import { executionSelectionState } from './executionSelection.svelte' import { toastState } from './toastsStore.svelte' import type { JobIdEntry } from '../types/jobTypes' @@ -161,7 +161,7 @@ export const jobsState = { /** Refreshes the job list from the appropriate source for the current execution location. */ async update(): Promise { try { - const isLocal = settingsState.execution.location === 'local' + const isLocal = executionSelectionState.location === 'local' jobs = isLocal ? await window.electron.invoke('list-local-runs', { numDays: JOB_LIST_DAYS, diff --git a/src/lib/stores/parametersStore.svelte.test.ts b/src/lib/stores/parametersStore.svelte.test.ts new file mode 100644 index 0000000..169f521 --- /dev/null +++ b/src/lib/stores/parametersStore.svelte.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import type { ParameterTree } from '../types/parameterTypes' +import { parametersState, setActiveLocation } from './parametersStore.svelte' + +const tree = (value: string): ParameterTree => + ({ Section: { field: { value } } }) as unknown as ParameterTree + +describe('parametersStore per-location isolation', () => { + it('resolves value/snapshot against the active location', () => { + setActiveLocation('local') + parametersState.value = tree('local-val') + + setActiveLocation('remote') + expect(parametersState.value).toBeNull() + parametersState.value = tree('remote-val') + + setActiveLocation('local') + expect(parametersState.snapshot).toEqual(tree('local-val')) + + setActiveLocation('remote') + expect(parametersState.snapshot).toEqual(tree('remote-val')) + }) +}) diff --git a/src/lib/stores/parametersStore.svelte.ts b/src/lib/stores/parametersStore.svelte.ts index 484ca7a..46ef8f5 100644 --- a/src/lib/stores/parametersStore.svelte.ts +++ b/src/lib/stores/parametersStore.svelte.ts @@ -3,36 +3,107 @@ import type { ParameterTree, ParameterNode, } from '../types/parameterTypes' +import type { ExecutionLocation } from '../types/settingsTypes' -let parameters: ParameterTree | null = $state(null) +type ParamsByLocation = Record -const stripUiMetadata = (node: ParameterNode): ParameterNode => { - if (typeof node !== 'object' || node === null) { - return node +/** + * Per-location executable parameter trees. Reads/writes resolve against the + * {@link activeLocation} so each target keeps its own validated + edited params. + */ +let paramsByLocation = $state({ + local: null, + remote: null, +}) + +/** + * Location whose parameter tree `value`/`snapshot` resolve against. Set by the + * execution-mode switch orchestration and the startup bootstrap. Plain internal + * state (no `settingsState` import) so this module stays free of electron side + * effects in test/util contexts. + */ +let activeLocation: ExecutionLocation = $state('remote') + +const PARAMS_KEY = 'execution_parameters' + +// Load persisted per-location params (no legacy migration — params were transient before). +const loadParameters = async () => { + if (!globalThis.window?.electron?.store) return + const stored = await window.electron.store.get(PARAMS_KEY, undefined) + if (stored && typeof stored === 'object') { + const v = stored as Partial + paramsByLocation = { + local: v.local ?? null, + remote: v.remote ?? null, + } } +} +loadParameters() - const entries = Object.entries(node) - .filter(([key]) => key !== '__extra') - .map(([key, value]) => { - if (typeof value === 'object' && value !== null) { - return [key, stripUiMetadata(value as ParameterLeaf | ParameterTree)] - } - return [key, value] - }) +/** + * Set which location's parameter tree `value`/`snapshot` resolve against. + * @param location - The execution location to make active. + */ +export const setActiveLocation = (location: ExecutionLocation): void => { + activeLocation = location +} - return Object.fromEntries(entries) as ParameterLeaf | ParameterTree +/** + * Write a location's parameter tree without changing the active location. + * Used when validating/syncing a non-active target from Settings. + * @param location - The execution location whose tree to set. + * @param value - The parameter tree (or null to clear). + */ +export const setValueFor = ( + location: ExecutionLocation, + value: ParameterTree | null +): void => { + paramsByLocation[location] = value + void persist() } export const parametersState = { get value() { - return parameters + return paramsByLocation[activeLocation] }, set value(v: ParameterTree | null) { - parameters = v + paramsByLocation[activeLocation] = v + void persist() }, get snapshot() { + const parameters = paramsByLocation[activeLocation] return parameters ? (stripUiMetadata($state.snapshot(parameters)) as ParameterTree) : null }, } + +// ── Private helpers ── + +const persist = async () => { + // globalThis.window (not bare window): value is set fire-and-forget, so it may + // resolve after a test env is torn down — never throw a ReferenceError there. + if (globalThis.window?.electron?.store) { + await window.electron.store.set( + PARAMS_KEY, + $state.snapshot(paramsByLocation) + ) + } +} + +const stripUiMetadata = (node: ParameterNode): ParameterNode => { + if (typeof node !== 'object' || node === null) { + return node + } + + const entries = Object.entries(node) + .filter(([key]) => key !== '__extra') + .map(([key, value]) => { + if (typeof value === 'object' && value !== null) { + return [key, stripUiMetadata(value as ParameterLeaf | ParameterTree)] + } + return [key, value] + }) + + return Object.fromEntries(entries) as ParameterLeaf | ParameterTree +} diff --git a/src/lib/stores/registryStore.svelte.test.ts b/src/lib/stores/registryStore.svelte.test.ts new file mode 100644 index 0000000..4715e39 --- /dev/null +++ b/src/lib/stores/registryStore.svelte.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { + setActiveLocation, + setRegistry, + mergeRegistry, + getAvailableNodes, + isNodeInRegistry, +} from './registryStore.svelte' + +const node = (type: string) => ({ + type, + node_type: 'constructor', + arguments: [], + inputs: [], + outputs: [-1], +}) + +describe('registryStore per-location isolation', () => { + it('keeps each location registry independent', async () => { + await setRegistry({ Local: node('Local') }, 'local') + await setRegistry({ Remote: node('Remote') }, 'remote') + + setActiveLocation('remote') + expect(isNodeInRegistry('Remote')).toBe(true) + expect(isNodeInRegistry('Local')).toBe(false) + + setActiveLocation('local') + expect(isNodeInRegistry('Local')).toBe(true) + expect(isNodeInRegistry('Remote')).toBe(false) + }) + + it('writes target an explicit location without moving the active cursor', async () => { + await setRegistry({ RemoteOnly: node('RemoteOnly') }, 'remote') + setActiveLocation('remote') + // Writing 'local' must not change what the active ('remote') reads resolve to. + await setRegistry({ LocalOnly: node('LocalOnly') }, 'local') + expect(isNodeInRegistry('RemoteOnly')).toBe(true) + expect(isNodeInRegistry('LocalOnly')).toBe(false) + }) + + it('merges into the given location without dropping existing nodes', async () => { + await setRegistry({ A: node('A') }, 'local') + await mergeRegistry({ B: node('B') }, 'local') + + setActiveLocation('local') + const types = getAvailableNodes().map((n) => n.type) + expect(types).toContain('A') + expect(types).toContain('B') + }) +}) diff --git a/src/lib/stores/registryStore.svelte.ts b/src/lib/stores/registryStore.svelte.ts index 162bc36..bff8c5d 100644 --- a/src/lib/stores/registryStore.svelte.ts +++ b/src/lib/stores/registryStore.svelte.ts @@ -8,6 +8,7 @@ import { import defaultNodesJson from '../data/defaultNodes.json' import defaultNetworkNodesJson from '../data/defaultNetworkNodes.json' import { filterValidNodes } from '../utils/registryValidator' +import type { ExecutionLocation } from '../types/settingsTypes' // ================= Registered nodes (sidebar) ======================== @@ -32,60 +33,139 @@ const sortRegistry = (nodes: RegisteredNodes): RegisteredNodes => { return Object.fromEntries([...elementary, ...rest]) } +type LocationRegistries = Record + +/** + * Per-location application registries (one coral node set per execution + * location). Reads resolve against the {@link activeLocation}; the palette and + * graph only ever see the active location's registry. + */ +let registries = $state({ + local: {}, + remote: {}, +}) + /** - * Application registry containing all the available nodes + * Location whose registry the read APIs resolve against. Set by the + * execution-mode switch orchestration and the startup bootstrap. Plain internal + * state (no `settingsState` import) so this module stays free of electron side + * effects in test/util contexts. */ -let registry = $state({}) +let activeLocation: ExecutionLocation = $state('remote') const persistStoreValue = async (key: string, value: unknown) => { - if (window.electron?.store) { + // globalThis.window (not bare window) so this never throws a ReferenceError in + // node/test contexts where `window` is undefined. + if (globalThis.window?.electron?.store) { await window.electron.store.set(key, value) } else { console.warn(`Electron store not available while persisting '${key}'`) } } -// Load registry from electron-store -const loadRegistry = async () => { - if (globalThis.window?.electron?.store) { - registry = sortRegistry( - await window.electron.store.get('registered_nodes', defaultNodes) - ) - } else { - registry = sortRegistry(defaultNodes) +// Type guard distinguishing the per-location shape from a legacy flat registry. +const isPerLocationRegistry = ( + value: unknown +): value is Record => { + if (!value || typeof value !== 'object') return false + const v = value as Record + return ( + typeof v.local === 'object' && + v.local !== null && + typeof v.remote === 'object' && + v.remote !== null + ) +} + +// Load per-location registries from electron-store, migrating an old flat value. +const loadRegistries = async () => { + if (!globalThis.window?.electron?.store) { + const fallback = sortRegistry(defaultNodes) + registries = { local: fallback, remote: fallback } console.warn('Electron store not available (e.g., dev:vite mode)') + return } + const stored = await window.electron.store.get('registered_nodes', undefined) + if (stored === undefined) { + const fallback = sortRegistry(defaultNodes) + registries = { local: fallback, remote: fallback } + } else if (isPerLocationRegistry(stored)) { + registries = { + local: sortRegistry( + Object.keys(stored.local).length ? stored.local : defaultNodes + ), + remote: sortRegistry( + Object.keys(stored.remote).length ? stored.remote : defaultNodes + ), + } + } else { + // Legacy flat registry — seed both locations from it and re-persist. + const flat = sortRegistry(stored as RegisteredNodes) + registries = { local: flat, remote: flat } + await persistStoreValue('registered_nodes', $state.snapshot(registries)) + } +} +loadRegistries() + +/** + * Set which location's registry the read APIs resolve against. + * @param location - The execution location to make active. + */ +export const setActiveLocation = (location: ExecutionLocation): void => { + activeLocation = location } -loadRegistry() /** - * Set the application registry for the available nodes. - * Filters out entries that are not valid StandardNodeDefinition objects - * @param data - Dictionary of node data to register - * @returns List of keys that were skipped due to invalid structure + * Replace the given location's registry with the given nodes (does not change + * the active location). Filters out entries that are not valid + * StandardNodeDefinition objects. + * @param data - Dictionary of node data to register. + * @param location - The execution location whose registry to replace. + * @returns List of keys that were skipped due to invalid structure. */ export const setRegistry = async ( - data: Record + data: Record, + location: ExecutionLocation +): Promise => { + const [filtered, skipped] = filterValidNodes(data) + registries[location] = sortRegistry(filtered) + await persistStoreValue('registered_nodes', $state.snapshot(registries)) + return skipped +} + +/** + * Merge the given nodes into the given location's registry (imported keys win), + * preserving nodes already registered for that location. Does not change the + * active location. + * @param data - Dictionary of node data to merge in. + * @param location - The execution location whose registry to merge into. + * @returns List of keys that were skipped due to invalid structure. + */ +export const mergeRegistry = async ( + data: Record, + location: ExecutionLocation ): Promise => { const [filtered, skipped] = filterValidNodes(data) - registry = sortRegistry(filtered) - console.log('Imported registry', $state.snapshot(registry)) - await persistStoreValue('registered_nodes', $state.snapshot(registry)) + registries[location] = sortRegistry({ + ...registries[location], + ...filtered, + }) + await persistStoreValue('registered_nodes', $state.snapshot(registries)) return skipped } /** - * Get all the available nodes from the registry + * Get all the available nodes from the active location's registry. * @remarks Returns reactive state - changes will trigger UI updates * @returns {StandardNodeDefinition[]} */ export const getAvailableNodes = (): StandardNodeDefinition[] => { - const nodes = Object.values(registry) + const nodes = Object.values(registries[activeLocation]) return nodes } /** - * Get node definition from the registry by type (snapshot for validation) + * Get node definition from the active location's registry by type (snapshot for validation) * @param {string} type - The node type identifier (e.g., 'Triangulation', 'DoFHandler') * @returns {StandardNodeDefinition} A snapshot (non-reactive copy) of the node definition for the given type * @throws {Error} If the node type is not found in the registry @@ -95,16 +175,16 @@ export const getNodeData = (type: string): StandardNodeDefinition => { console.error(`Node type '${type}' was not found in the available nodes.`) throw new Error(`Node type '${type}' was not found in the available nodes.`) } - return $state.snapshot(registry[type]) + return $state.snapshot(registries[activeLocation][type]) } /** - * Returns if node is present in the registry of the available nodes + * Returns if node is present in the active location's registry of available nodes * @param {string} type - The node type identifier (e.g., 'Triangulation', 'DoFHandler') * @returns {boolean} True if present, false if not */ export const isNodeInRegistry = (type: string): boolean => { - return type in registry + return type in registries[activeLocation] } // ============ Registered network nodes section (sidebar) ====================== diff --git a/src/lib/stores/settingsStore.svelte.ts b/src/lib/stores/settingsStore.svelte.ts index 4d01064..b7c4a37 100644 --- a/src/lib/stores/settingsStore.svelte.ts +++ b/src/lib/stores/settingsStore.svelte.ts @@ -3,6 +3,9 @@ import { isValidAppSettings, type AppSettings, type ExecutionSettings, + type ExecutionLocation, + type BackendKind, + type ProbeResult, } from '../types/settingsTypes' import { toastState } from './toastsStore.svelte' @@ -16,7 +19,7 @@ const loadSettings = async () => { // Key absent — first launch or isolated E2E store. Silently use defaults. settings = createDefaultSettings() } else if (isValidAppSettings(storedSettings)) { - settings = storedSettings + settings = normalizeProbes(storedSettings) } else { // Key present but schema is wrong (e.g. after an app upgrade). settings = createDefaultSettings() @@ -56,14 +59,16 @@ export const settingsState = { get local() { return settings.execution.local }, - get isExecutableMode() { - return settings.execution.backendKind === 'executable' + /** Parameters file name configured for the given location's target. */ + getParametersFileName(location: ExecutionLocation): string { + return settings.execution[location].parametersFileName }, - get isCoralMode() { - return settings.execution.backendKind === 'coral' - }, - get activeParametersFileName() { - return settings.execution[settings.execution.location].parametersFileName + /** Probe status recorded for a specific location × backend kind. */ + getProbe( + location: ExecutionLocation, + backendKind: BackendKind + ): ProbeResult | undefined { + return settings.execution[location].probes?.[backendKind] }, async saveUrlVisualizer(url: string) { await persistSettings({ ...settings, urlVisualizer: url }) @@ -71,14 +76,42 @@ export const settingsState = { async saveUrlRemoteServer(url: string) { await persistSettings({ ...settings, urlRemoteServer: url }) }, - async saveExecution( - execution: ExecutionSettings, - lastProbe: NonNullable + /** Persist the edited path fields, preserving each target's probe status. */ + async saveExecutionPaths(execution: ExecutionSettings) { + await persistSettings({ + ...settings, + execution: { + ...execution, + local: { ...execution.local, probes: settings.execution.local.probes }, + remote: { + ...execution.remote, + probes: settings.execution.remote.probes, + }, + }, + }) + }, + /** Record a probe outcome into the given location × backend kind slot. */ + async recordProbe( + location: ExecutionLocation, + backendKind: BackendKind, + status: ProbeResult ) { - await persistSettings({ ...settings, execution, lastProbe }) + const target = settings.execution[location] + await persistSettings({ + ...settings, + execution: { + ...settings.execution, + [location]: { + ...target, + probes: { ...target.probes, [backendKind]: status }, + }, + }, + }) }, - async saveParametersFileName(parametersFileName: string) { - const location = settings.execution.location + async saveParametersFileName( + location: ExecutionLocation, + parametersFileName: string + ) { await persistSettings({ ...settings, execution: { @@ -98,3 +131,19 @@ const persistSettings = async (nextSettings: AppSettings) => { settings = nextSettings await window.electron.store.set('settings', $state.snapshot(settings)) } + +// Backfill the per-target `probes` map for settings persisted before it existed. +const normalizeProbes = (stored: AppSettings): AppSettings => ({ + ...stored, + execution: { + ...stored.execution, + local: { + ...stored.execution.local, + probes: stored.execution.local.probes ?? {}, + }, + remote: { + ...stored.execution.remote, + probes: stored.execution.remote.probes ?? {}, + }, + }, +}) diff --git a/src/lib/types/settingsTypes.ts b/src/lib/types/settingsTypes.ts index 4071ba1..f0c08a7 100644 --- a/src/lib/types/settingsTypes.ts +++ b/src/lib/types/settingsTypes.ts @@ -26,6 +26,10 @@ export type ExecutionTargetSettings = { executablePath: string parametersFileName: string workingDirectory: string + // Probe status per backend kind for this target (paths/reachability differ per + // target, so status is stored here; the validated payload lives in the + // per-location registry/parameters store). + probes: Partial> } export type RemoteExecutionSettings = ExecutionTargetSettings & { @@ -36,24 +40,45 @@ export type RemoteExecutionSettings = ExecutionTargetSettings & { } export type ExecutionSettings = { - location: ExecutionLocation - backendKind: BackendKind local: ExecutionTargetSettings remote: RemoteExecutionSettings } +/** + * A single validation outcome for one target × backend kind. Status only — the + * heavy payload (node registry / parameters template) is routed to its own + * per-location store, not stored here. + */ export type ProbeResult = { ok: boolean message: string - metadata?: ExecutionMetadata syncedAt?: string } +/** + * The renderer-facing return of a probe: the small persisted {@link ProbeResult} + * status plus the transient {@link ExecutionMetadata} payload to apply to the + * per-location registry/parameters store. + */ +export type ProbeResponse = { + status: ProbeResult + metadata: ExecutionMetadata +} + +/** + * The minimal, structured-cloneable payload sent to the probe IPC: only the + * active target plus which location/kind it is, not the whole settings object. + */ +export type ProbeRequest = { + location: ExecutionLocation + backendKind: BackendKind + target: ExecutionTargetSettings | RemoteExecutionSettings +} + export type AppSettings = { urlVisualizer: string urlRemoteServer: string execution: ExecutionSettings - lastProbe: ProbeResult | null } const defaultExecutionTargetSettings = (): ExecutionTargetSettings => ({ @@ -62,14 +87,13 @@ const defaultExecutionTargetSettings = (): ExecutionTargetSettings => ({ executablePath: '', parametersFileName: 'parameters.json', workingDirectory: '', + probes: {}, }) export const createDefaultSettings = (): AppSettings => ({ urlVisualizer: 'http://localhost:8008', urlRemoteServer: 'http://localhost:8080', execution: { - location: 'remote', - backendKind: 'coral', local: defaultExecutionTargetSettings(), remote: { ...defaultExecutionTargetSettings(), @@ -82,7 +106,6 @@ export const createDefaultSettings = (): AppSettings => ({ coralPluginPath: '/app/build/backends/dealii/libcoral_backend_dealii.so', }, }, - lastProbe: null, }) /** @@ -97,8 +120,7 @@ export const isValidAppSettings = (value: unknown): value is AppSettings => { const v = value as Record if (!v.execution || typeof v.execution !== 'object') return false const exec = v.execution as Record - if (exec.location !== 'local' && exec.location !== 'remote') return false - if (exec.backendKind !== 'coral' && exec.backendKind !== 'executable') - return false + if (!exec.local || typeof exec.local !== 'object') return false + if (!exec.remote || typeof exec.remote !== 'object') return false return true } diff --git a/src/lib/utils/settingsActions.ts b/src/lib/utils/settingsActions.ts index 4c38559..fe8c0b5 100644 --- a/src/lib/utils/settingsActions.ts +++ b/src/lib/utils/settingsActions.ts @@ -1,66 +1,110 @@ -import type { ExecutionSettings, ProbeResult } from '../types/settingsTypes' -import { parametersState } from '../stores/parametersStore.svelte' +import type { + ExecutionSettings, + ExecutionLocation, + ExecutionMetadata, + BackendKind, + ProbeRequest, + ProbeResponse, + ProbeResult, +} from '../types/settingsTypes' +import type { ParameterTree } from '../types/parameterTypes' +import { setValueFor as setParamsFor } from '../stores/parametersStore.svelte' import { setRegistry } from '../stores/registryStore.svelte' import { settingsState } from '../stores/settingsStore.svelte' +import { toastState } from '../stores/toastsStore.svelte' /** - * Probes the backend with the given execution settings, applies any synced metadata - * (node registry or parameters template), and persists the settings on success. + * Probes the given target, routes any synced payload (node registry or parameters + * template) to the location's store, records the probe status for that + * location × backend kind, and persists the paths. * - * @param execution - The execution settings to probe and save. - * @returns The probe result object from the backend, or an error result. + * @param location - The execution location being validated. + * @param backendKind - The backend kind being validated. + * @param execution - The full runner config (both targets) to persist. + * @returns The probe status, or an error status if the probe could not run. */ -export const probeAndSaveExecution = async (execution: ExecutionSettings) => { +export const probeAndSaveExecution = async ( + location: ExecutionLocation, + backendKind: BackendKind, + execution: ExecutionSettings +): Promise => { if (!window.electron?.invoke || !window.electron?.store) { return { ok: false, message: - 'Save & Sync Execution is available only in the Electron app, not in dev:vite mode.', - warnings: [], + 'Validate & Sync is available only in the Electron app, not in dev:vite mode.', } } - let probeResult + // Send only the active target (not the whole settings object) so the probe IPC + // is decoupled from the full settings shape. + const request: ProbeRequest = { + location, + backendKind, + target: execution[location], + } + + let response: ProbeResponse try { - probeResult = await window.electron.invoke( + response = await window.electron.invoke( 'probe-sync-execution-settings', - execution + request ) } catch (error) { return { ok: false, message: (error as Error)?.message || 'Configuration probe failed', - warnings: [], } } - if (!probeResult?.ok) return probeResult + const { status, metadata } = response + if (!status.ok) return status - await applySyncedMetadata(probeResult) - await settingsState.saveExecution(execution, probeResult) - if ( - probeResult.metadata?.kind === 'parametersTemplate' && - probeResult.metadata.parametersFileName - ) { + await applySyncedMetadata(location, metadata) + await settingsState.saveExecutionPaths(execution) + await settingsState.recordProbe(location, backendKind, status) + if (metadata?.kind === 'parametersTemplate' && metadata.parametersFileName) { await settingsState.saveParametersFileName( - probeResult.metadata.parametersFileName + location, + metadata.parametersFileName ) } - return probeResult + return status +} + +/** + * Toasts a hint when the given location × backend kind has no successful probe + * recorded yet, pointing the user to Settings. Call after a mode/location switch. + * + * @param location - The execution location just selected. + * @param backendKind - The backend kind just selected. + */ +export const warnIfUnvalidated = ( + location: ExecutionLocation, + backendKind: BackendKind +) => { + if (!settingsState.getProbe(location, backendKind)?.ok) { + toastState.add({ + message: `Configuration for ${location}/${backendKind} not validated yet — open Settings and Validate & Sync.`, + type: 'error', + }) + } } // ── Private helpers ── -const applySyncedMetadata = async (probeResult: ProbeResult) => { - const { metadata } = probeResult +const applySyncedMetadata = async ( + location: ExecutionLocation, + metadata: ExecutionMetadata +) => { if (!metadata) return if (metadata.kind === 'nodeRegistry') { - await setRegistry(metadata.data) + await setRegistry(metadata.data, location) return } if (metadata.kind === 'parametersTemplate') { - parametersState.value = metadata.data as any + setParamsFor(location, metadata.data as unknown as ParameterTree) } } diff --git a/src/lib/utils/sshMessages.ts b/src/lib/utils/sshMessages.ts index 9b66323..7a661d8 100644 --- a/src/lib/utils/sshMessages.ts +++ b/src/lib/utils/sshMessages.ts @@ -28,6 +28,7 @@ import { settingsState } from '../stores/settingsStore.svelte' import { parametersState } from '../stores/parametersStore.svelte' import { serializeParametersFile } from './parameterFileFormat' import { buildDirName } from './slugify' +import type { ExecutionLocation } from '../types/settingsTypes' /** * Executes a test SSH command using password authentication. @@ -95,12 +96,12 @@ export type JobConfig = CoralJobConfig | ExecutableJobConfig * @remarks Callers should pass snapshots using $state.snapshot() or snapshot() */ export const exportAndEvalCoralGraph = async ( + location: ExecutionLocation, nodes: Node[], edges: Edge[], config: CoralJobConfig, runName?: string ): Promise => { - const { location } = settingsState.execution if (location === 'local') { await exportAndEvalGraphLocal(nodes, edges, config) } else if (location === 'remote') { @@ -109,10 +110,10 @@ export const exportAndEvalCoralGraph = async ( } export const exportAndEvalExecutable = async ( + location: ExecutionLocation, config: ExecutableJobConfig, runName?: string ): Promise => { - const { location } = settingsState.execution if (location === 'local') { await exportAndEvalExecutableLocal(config) } else if (location === 'remote') { @@ -606,14 +607,15 @@ export const fetchRemoteJobs = async (numDays: number): Promise => { /** * Retrieves the content of the .out file for a specific job ID. + * @param location - Where the job ran (`local`/`remote`). * @param jobId - The ID of the Slurm job * @returns A promise that resolves to the content of the file */ export const getOutFileContent = async ( + location: ExecutionLocation, jobId: string | number ): Promise => { - const execution = settingsState.execution - if (execution.location === 'local') { + if (location === 'local') { return await window.electron.invoke('get-local-run-log', { jobId }) } @@ -621,7 +623,7 @@ export const getOutFileContent = async ( // to global settings only for jobs recorded before per-run directories existed. const outputDirectory = jobIdMapState.getJobWorkingDirectory(String(jobId)) ?? - execution.remote.workingDirectory + settingsState.remote.workingDirectory const command = `cat ${shellEscape(`${outputDirectory}/slurm-${jobId}.out`)}` return await window.electron.invoke('execute-ssh-with-key', { command: command, @@ -638,25 +640,26 @@ export const getOutFileContent = async ( * // Map { '9' => ['running', 'succeeded'], '12_1' => ['running', 'failed'], '12_2' => ['running'] } */ export const getNodesExecutionStatus = async ( + location: ExecutionLocation, jobIdInternal: number ): Promise> => { - const execution = settingsState.execution - // Use the job's own backend kind / directory (a pipeline can mix kinds and dirs), - // falling back to the global settings for jobs predating per-job tracking. + // Use the job's own backend kind / directory (a pipeline can mix kinds and dirs). + // Node status is a coral-only concept, so default the legacy no-entry case to + // 'coral' (an executable job short-circuits to an empty map). const entry = jobIdMapState.getEntryByInternal(jobIdInternal) - const backendKind = entry?.backendKind ?? execution.backendKind + const backendKind = entry?.backendKind ?? 'coral' if (backendKind === 'executable') { return new Map() } // define the command to list the files in the touch-dir let output = '' - if (execution.location === 'local') { + if (location === 'local') { output = await window.electron.invoke('get-local-node-status-files', { jobIdInternal, }) - } else if (execution.location === 'remote') { + } else if (location === 'remote') { const statusDirectory = - entry?.workingDirectory ?? execution.remote.workingDirectory + entry?.workingDirectory ?? settingsState.remote.workingDirectory try { output = await window.electron.invoke('execute-ssh-with-key', { command: `ls -tr ${shellEscape(`${statusDirectory}/nodes-exec-status/${jobIdInternal}`)}`,