NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Update transactions and durable requests
Developer reference

Update transactions and durable requests

Owning code:

  • src/update/transaction.ts: runUpdateTransaction (line 135), the single executable transaction.
  • src/update/request.ts: the durable queue in <updateDir>/requests.json. readQueue (line 37), enqueuePluginOp (line 57), enqueuePluginOpsLocked (line 64) and enqueueCoreRun (line 74).
  • src/update/pluginMutation.ts: withPluginMutation (line 14), the lifecycle fence.
  • src/update/report.ts: writeReport (line 163), retireUpdateRequests (line 188) and acknowledgeUpdateRequest (line 207).
  • src/update/journal.ts: the journal, which accepts schema 2 only (line 154).
  • src/plugins/manifest.ts: validateManifestFolder (line 42). src/plugins/installReceipt.ts: the receipt shape and its strict parser.
  • src/cli/update.ts: candidate selection, lock modes and queue draining. acquireUpdateLock is at line 94.
  • src/cli/updateTransactionIO.ts: defaultTransactionIO (line 243), which wires the real core and marketplace halves.
  • src/cli/rootUpdateStep.ts: runInstallReleaseStep (line 111) and refreshInstalledHost (line 88).
  • src/shared/updateBudgets.ts: the phase deadlines and the TimeoutStartSec value.
  • src/cli/install/systemdUnits.ts: the coordinator .path and .service units. The queue path is at line 270 and TimeoutStartSec at lines 179 and 294.
  • src/api/routes/system.ts: GET /system/update (line 105), POST /system/update (line 117) and the acknowledgement route (line 128).
  • src/shared/version.ts: isNewer (line 3).

Coordinator publication uses writeReport(updateDir, report, transaction) with a required source containing ops and coreRequest. The CLI's local writeTerminalReport maps normal completion and automatic or manual recovery through this same writer, preserving finishedAt, checked, optional notes and per-request verdicts. Preparations and retirements use the private writer without a journal; absence of transaction provenance there adds no journal-derived verdicts. Reports remain status hints, retain at most 100 operations for 24 hours and carry no installation or recovery authority.

retireUpdateRequests(updateDir, requests, detail) requires the caller's cancellation reason. Replacement producers retain the replacement detail; dropQueuedOpsLocked identifies a plugin disabled or removed before execution. Callers hold requests.lock, and report.lock protects the durable verdict publication before queue removal. An empty request set writes nothing. The durable superseded state and owner-scoped acknowledgement contract do not change. Plugin disable and uninstall are real consumers.

Installer package paths and update coordinator provisioning reuse shared/installResolution.installedPackagePath. Installed and retained modules resolve to the stable package link; a source checkout returns null and these consumers retain their source package root. An explicitly injected CLI path is preserved and participates in source-checkout detection. No new install-kind resolver or filesystem lookup is introduced.

installSpec and convergeUpdateCoordinator reuse shared/paths.dbPath with the explicitly resolved service HOME. Coordinator resolution applies inline unit environment followed by EnvironmentFile values, then uses the service account HOME when no HOME assignment exists. Without a non-empty database override or usable service HOME it fails before publication; it never derives instance state from root's process HOME. Literal-path and install-record identity checks remain in the coordinator. Privileged refresh is a real consumer.

refreshPrivilegedWorker returns Promise and reports its effects through the supplied log callback. runPrivileged awaits it and returns the command exit code. File changes retain the drained cutover, unit-only changes retain one worker restart, and unchanged files retain the existing current-state log. The real withUpdateTimer switch remains available for candidate installation; only RefreshAfterUpdateIO.converge removes its constant timer argument and calls the real converger with true.

Single executable update transaction

Core releases and every publisher-plugin install, update and repair use runUpdateTransaction in src/update/transaction.ts. The coordinator plans from install receipts and unchanged trees, stages with the target parser, preflights the effective baseline plus replacements, stops services, records and applies swaps, starts the candidate, proves the whole set, then commits or restores and proves the old set. Every run plans its explicit requests plus every enabled publisher plugin with a newer version, so one request moves the whole stale set: after a shared-API bump the preflight refuses any set in which a loaded plugin still needs the old API, and one-plugin runs would each roll back. The preflight reads, like the loader, only plugins the target loads (the enabled set plus enabling requests) and every staged replacement; a disabled plugin is never parsed and advances when it is enabled. Enabled customer plugins participate in compatibility preflight but are never changed by this path. src/plugins/manifest.ts owns validateManifestFolder, the common post-parse identity and lexical entry-containment check used by loader, marketplace staging and preflight. Callers pass the logical folder name independently of the physical staging path and receive the resolved entry path. Preflight still parses with the target core and verifies realpath containment and regular files for plugin and service entries. Marketplace still rejects staging symlinks and bounds its tree size; loader still imports the validated entry. These filesystem and execution boundaries remain distinct. A manifest the running parser rejects does not erase a publisher plugin's installed identity: the receipt supplies its installed version and the listings expose degraded with the load failure reason.

The previous state is observed before each swap. from: null and backup: null mean the folder was actually absent, not that the operation was called an install or repair. An existing unloadable folder keeps a backup. expectLoad records enablement after the transaction: enabled replacements must load alongside the baseline; disabled replacements are proven by their tree inventory. Before changing configuration in the stopped window, the journal records the enablement delta. The narrow ConfigStore.setPluginEnabled changes only plugins.enabled under its existing write lock and revision. Recovery reverts that delta idempotently without overwriting unrelated settings or a later operator change.

The stop window includes sockets and independent services executing from affected plugin folders, not only the daemon and web. They must be stopped before either forward swaps or rollback. Service names come from each installed raw manifest, so an old API rejection cannot omit an old unit. A missing plugin folder is observed absence; a malformed existing manifest is a failure. Unit load state is read through the narrowly granted service command: only not-found skips an unprovisioned unit. The daemon's existing independent-service lifecycle provisions and starts new units; the coordinator never invents a second readiness policy. The boot report is completed after pluginServices.startAll settles, with pluginServices results and servicesSettled; the proof requires successful convergence for swapped independent services. If the candidate cannot stop, no backup is renamed underneath it: needsOperator preserves the set. During candidate boot a validated swap intent in the active journal establishes publisher ownership, so a new plugin can register before it has its final receipt. Receipts are published only after proof. Receipt publication failure retains committing, swap intents and backups, never a terminal success. Recovery republishes every receipt idempotently before admitting another transaction; candidate boot continues to recognize publisher ownership from those active intents. src/plugins/installReceipt.ts owns the receipt shape and its strict parser. The receipt store and journal both import this contract directly; journal validation never imports a store that reads the journal. Marketplace candidates and their fixtures use the same contract, without a second parser.

Durable update requests

src/update/request.ts is the core producer/consumer seam, not another installer or recovery store. It stores normalized operations in <updateDir>/requests.json: at most one install, update or repair per plugin, with enablement, an optional approved version/checksum pin, request id, time and account. An optional core request selects the whole-instance update while retaining explicit plugin operations. The prepared journal stores it as coreRequest, alongside ops; the timer explicitly requests a core run, whereas a marketplace start is a plugin-only run that still takes every eligible enabled plugin update along with its queued requests. Minimal use for a boot repair is:

enqueuePluginOp(updateDir, { name, op: 'repair', enable: true, pin: null, by: null });

enqueueCoreRun(updateDir, by, uiInitiated?) (src/update/request.ts:74) queues a whole-instance run. POST /system/update calls it and answers 202 with queued: true (src/api/routes/system.ts:117-124).

An explicit update whose unchanged installed receipt already has the offered version is satisfied as applied, with no swap or restart. A pin additionally requires the installed artifact checksum to match; a changed registry pin is superseded, and a downgrade remains cannot-stage and failed. Satisfied requests are not deferral warnings. Existing bad reports are replaced only by a subsequent real coordinator run, not by a migration or a presentation override.

enqueuePluginOpsLocked accepts several existing operation shapes under requests.lock, validates them all and publishes exactly one durable queue write. enqueuePluginOp uses this same writer. Settings Plugins calls admin POST /plugins/marketplace/update with optional acknowledgeGrants keyed by plugin name. The route prepares every catalog updateAvailable entry, including disabled ones, gates all consent, controls and licences before publishing, and preserves enablement. Its final withPluginMutation check shares the claim barrier and returns the existing 409 with request id when a transaction owns a member. Nothing offered adds no operations. The 202 returns queued: true and the accepted plugins operations with individual request ids; it neither updates core nor adds a second coordinator, poll or restart. One queue claim owns the entire batch and its one stop/start.

The batch route is POST /plugins/marketplace/update (src/api/routes/plugins/marketplace.ts:84-112). The single-plugin routes are POST /plugins/marketplace/:name/install and POST /plugins/marketplace/:name/update (lines 143-144). A single-plugin route answers 202 with its requestId and queued: true (line 140).

Marketplace routes first gate the candidate's consent, controls and entitlement, then enqueue the pinned operation. The enabled elowen-update-coordinator.path has PathExists=<updateDir>/requests.json and activates elowen-update-coordinator.service, running elowen update --queued. This explicit mode waits up to ten minutes for the existing kernel update lock through acquireKernelLock's timeoutMs. Phase deadlines come from src/shared/updateBudgets.ts: ten minutes for ownership, sixteen minutes for privileged-worker drain, and three minutes for each boot proof. Candidate and rollback can each need a drain and proof, already exceeding the former twenty-minute aggregate deadline; planning, host-lock waits, refresh and additional queued runs add further time. Both update services therefore render TimeoutStartSec=infinity: there is no finite bound on the number of transactions drained by one activation, and PID 1 must not kill a swap. Phase deadlines still fail normally. After acquiring the lock, unattended modes read the installed core inventory from the stable package prefix before recovery, selection or request claim. If its version differs from the loaded CLI, they return success with checked: false, preserving the queue, journal and report. A stale queued process hands the unchanged queue to the current CLI through the path watcher; a stale timer leaves any pending force-update signal unacknowledged for the next tick. Source checkouts have no installed core identity. A current coordinator recovers an interrupted journal, then drains only queued requests. An empty queue returns success with Nothing queued. without a feed read, core enqueue or replacement report. Real failures, rollback and operator attention produce a non-zero exit. The timer uses automatic mode: it waits on the same lock and always requests a full check; an operator retains fail-fast locking and the existing selection behavior. PID 1 rechecks path presence immediately after the oneshot terminates, even after a successful stale exit, including a request enqueued after its final empty read. This is the documented systemd.path behavior; service start rate limits still apply. Empty queues remove the file and fsync its directory under requests.lock. There is no daemon-side service-start trigger or sudoers grant for starting the coordinator. The System button, timer and CLI share that coordinator; a one-plugin marketplace update also advances every other enabled plugin with a newer version, in the same run and restart. Manual requests work with automatic updates off. Boot repair, after the registry is built, queues enabled receipted plugins that are missing or unloaded. It reports customer failures without repairing them, uses the last-good cache offline, and does not repeat a version whose activation already failed proof. Operator repair is an explicit retry through POST /plugins/marketplace/:name/repair, which answers 409 plugin does not need repair unless the plugin is a repair candidate, degraded, or missing its receipt (src/api/routes/plugins/marketplace.ts:145-154). Boot repair calls enqueuePluginOp at src/daemon/bootstrap.ts:447, and it is skipped while a journal is active (src/daemon/bootstrap.ts:431-432).

CLI candidate selection, licence renewal, request draining and recovery entrypoints stay in src/cli/update.ts. Its defaultTransactionIO(ctx) collaborator in src/cli/updateTransactionIO.ts wires the real core and marketplace halves, preflight, boot report and supervisor. It owns the narrower TransactionIODeps port, which UpdateDeps extends; the wiring has no import back to the coordinator. The marketplace opens the daemon's DB with migrate: false, and its handle closes once after proof, rollback or an unused planning/recovery pass. Initial discovery and preflight share one bundled plugin path; preparing a core candidate changes only preflight's staged path and verified manifest parser. The production update command and the offline API-transition harness use this same wiring. The offline harness packages a production manifest and matching root lock under the release archive's package/ prefix, runs real tar extraction and toolchain probing, and substitutes only the pinned npm ci command with fixture dependency links. Candidate boot and exact rollback remain real.

src/cli/rootUpdateStep.ts owns runInstallReleaseStep, the structured outcome parser, installed CLI resolution, core-record selection and refreshInstalledHost(exec, args). The CLI dispatches the pinned root command directly to this module before environment or credential handling. The module does not open the instance DB or import the coordinator; stage, install, refresh, commit, restore and clean still travel solely through the existing stdin protocol. Both the coordinator's final refresh and the transaction's root convergence re-execute the installed release through the shared refresh helper. A service-user writable prefix keeps its instance record; retained root recovery uses the trusted record. No installation in a source checkout means core swaps are unavailable, while queued plugin work retains its existing behavior. The split adds no worker, polling, hook or alternate update path.

The short requests.lock is separate from the update lock: a producer must never wait on the updater that is about to stop its daemon. Later actions supersede queued operations for the same name; disable or uninstall removes them. With no queue file, reads return an empty queue and do no work. The coordinator takes requests and writes their operations into a new prepared journal under the request lock before clearing the queue. A crash before the write retains the queue; recovery after it uses the journal and requeues prepared operations. It drains newly arrived requests in further runs; the path watcher also activates leftover queues at boot. An unreadable queue is a failure, not a success-shaped empty queue.

The queue is bounded work, never a record of installed bytes. The file is capped at 256 KiB (src/update/request.ts:30). An oversized file, a file that fails the queue schema, or a plugin named twice throws instead of reading as an empty queue (src/update/request.ts:40-43). Pins prevent applying a catalog version the operator did not approve: a changed pin ends superseded. Consent and the renewed licence are rechecked when staging; refusals become per-request outcomes. Requests are claimed before licence renewal so a lost licence also yields terminal not-licensed, rather than an indefinitely queued id. An active non-terminal journal, including rollback and operator recovery, suppresses automatic boot repair and fences adoption and uninstall. withPluginMutation in src/update/pluginMutation.ts fences enable, disable and uninstall, plus actual plugins.enabled differences in PUT /config, under that same request lock. Frozen names include request ops, swaps, the loaded proof baseline and enablement deltas. Refusals are HTTP 409 { error: "update in progress", requestId }, correlated to the plugin op/swap, core request, or run id. The server maps UpdateInProgressError to that body (src/api/server.ts:58). The synchronous callback performs the write and queued cancellation without awaiting or nesting request locks. Uninstall checks before external cleanup and again at its final marker/config write. Unrelated names and terminal journals do not block these lifecycle writes. Adoption still fences the whole active transaction. A real consumer is the Plugins disable toggle, PATCH /plugins/:name (src/api/routes/plugins/index.ts:415-426). Uninstall is fenced inside src/plugins/marketplace.ts:380, and the PUT /config path is fenced at src/api/routes/config.ts:124. Boot sweep is only bounded housekeeping of parked deletions, debris and host dependency links, with neither an active journal nor a running updater.

There is no daemon-side executable swap, candidate-boot appended swap intent, pending-<name>, pendingRecorder or settleInstalls path. Settle old pending work with the previous release before cutover; there is no compatibility migration. The journal alone owns crash recovery and accepts only schema 2 (src/update/journal.ts:154). A schema-1 journal fails with its filename and instructions to finish with the older updater or archive it only after proven recovery. Customer transition is operator-managed, not an automatic old-coordinator update. Admin GET /system/update (src/api/routes/system.ts:105-115) exposes the queue, journal summary and last report, including request ids and per-plugin applied, rollback, consent, licence, superseded, failed and skipped outcomes. Shared update record types, including SkippedCandidate, belong to src/shared/wireContract.ts; selection, reporting and CLI consumers import them directly. Marketplace owns candidate selection, not the report contract, so request retirement can publish a report without a dependency back into marketplace or the journal. The report is a status hint, not installation authority. Its run summary remains the latest run; its optional operations list retains up to 100 request verdicts for 24 hours across unrelated runs. Every consumed core and plugin request id is listed in that run. writeReport(updateDir, report, transaction) copies the request's owner, UI provenance and request timestamp from the claimed journal before another transaction replaces it, and combines them with the matching per-request verdict. A missing plugin verdict fails publication instead of attributing the whole run's success to that plugin. The journal's optional requestOutcomes carries selection verdicts for requests without swaps, so interrupted commit recovery preserves exact consent, licensing and already-current decisions. Proven commits and rollbacks record finishedAt before publication. If a crash leaves either terminal journal without matching report request ids, recovery reconstructs its exact swap and non-swap verdicts before another run can replace it. The restored report uses that completion time; a missing time is checkpointed once during reconstruction. Already-reported requests return no recovery outcome, preserving their timestamps and acknowledgements across later coordinator starts. Stop/start failures before the first swap also publish failed results for each selected plugin request, including the needsOperator branch. Recovery also reports requests interrupted before a swap as failed for that run; requeued work takes precedence in the dock. Queue normalization and disable/uninstall publish an explicit superseded verdict before removing a request.

Settings core, single-plugin and batch-plugin updates, installs and repairs send uiInitiated: true (web/lib/elowenClient.ts:314, 423, 425); the daemon records it only for a full human login through fullHumanLogin, never a turn credential, personal API token, setup request or unmarked CLI request. Absent provenance means the request does not dock. The existing queue and journal schemas carry this optional positive UI claim without another store. The browser's pure updateDockOperations(status, userId) projects only that person's marked requests, using update:<requestId> as the dock id and null progress because the coordinator supplies no percentage. It follows the shared admin-only useSystemUpdateStatus query through reload and daemon reconnect; the update dialog (web/components/ui/UpdateOperationProgressDialog.tsx) composes the shared Modal, Button and Progress and Settings state translations. The query is useSystemUpdateStatus (web/lib/queries.ts:206), and the dock projection is updateDockOperations (web/lib/operationDockUpdates.ts:20).

Plugin install and single-update preparation publishes a downloading operation in the same report before prepareCandidate awaits the registry package. The record is feedback only: no queue is published until the exact artifact passes consent and dependency checks. enqueuePluginOp accepts that preparation's {requestId, requestedAt}, preserves the approved artifact pin, publishes the queue durably, then removes the preparation record under the same producer barrier. Queue and journal carry progress from that point, and terminal report projection copies op from the plugin request so install, update and repair keep their identity. Download failures and consent/dependency refusals retain the exact request outcome and can be acknowledged. The repair route has its own strict uiInitiated body and existing receipt/eligibility checks; its package preparation already belongs to the coordinator and stays visible through queued/prepared states. Daemon bootstrap runs recoverPluginPreparations once: orphaned downloads become failed with an interruption reason; matching queue or journal ids remove stale preparation feedback and leave coordinator recovery authoritative. It never runs in attached runners. All reads remain bounded by the existing report limits.

A content-free update SSE event carries only the initiating account id, is delivered only to that current administrator, and invalidates only QUERY_KEYS.systemUpdate. Marketplace publication emits it before awaiting the download and after queue acceptance or refusal; the shell's existing query then polls active downloads once per second as well as queued/coordinator work. No event payload duplicates an operation row, and event recording skips this transient nudge even when a plugin supplies a row resolver.

POST /system/update/operations/ack { requestId } acknowledges only the authenticated human's own marked, settled request. Unknown and foreign ids return 404, active requests return 409, and repeated acknowledgement returns the original timestamp. Acknowledgement and coordinator publication serialize under report.lock; queue-changing actions and the route acquire requests.lock first. Report writes fsync both bytes and directory, preserve acknowledgements and prior verdicts, and remain bounded by the existing 256 KiB report limit. Confirmed requests disappear from the dock across tabs, devices, reloads and daemon restarts; failed confirmation keeps the item until a successful authoritative read. No request verdict means no invented completed dock item. The report retains failedActivations across unrelated runs, derived only from journaled swaps whose candidate startup was attempted. Boot repair uses the injected read-only receipt store to distinguish publisher from customer identity and compares the failed version to the cached target catalog version. Staging, licence, consent and offline refusals do not set this guard; an explicit retry that proves the same version clears it. A terminal rollback journal also supplies the guard if the report write was lost.

Release comparison and CLI readiness

src/shared/version.ts owns isNewer(candidate, current) for the core updater, marketplace compatibility checks and configuration API. It compares plain numeric dot segments, accepts an initial v and pads missing segments with zero; it does not introduce prerelease ordering. Callers keep their existing release validation. Comparison is pure and performs no package or network reads.

CLI doctor uses the existing urlHealthy(url, fetchFn, timeoutMs) launcher probe (src/cli/launcher.ts:140), also used by startup and installer readiness. The default request deadline is three seconds, any non-2xx or transport/abort failure answers false, and the timer is cleared after settlement. A failed doctor probe exits before authentication or readiness reads. Licence activation keeps its distinct injected fetchHealth response seam because it inspects the locked response body rather than a boolean.