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) andenqueueCoreRun(line 74).src/update/pluginMutation.ts:withPluginMutation(line 14), the lifecycle fence.src/update/report.ts:writeReport(line 163),retireUpdateRequests(line 188) andacknowledgeUpdateRequest(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.acquireUpdateLockis 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) andrefreshInstalledHost(line 88).src/shared/updateBudgets.ts: the phase deadlines and theTimeoutStartSecvalue.src/cli/install/systemdUnits.ts: the coordinator.pathand.serviceunits. The queue path is at line 270 andTimeoutStartSecat 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
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.