diff --git a/docs/content-sync.md b/docs/content-sync.md index 8d26ff04..1439eb42 100644 --- a/docs/content-sync.md +++ b/docs/content-sync.md @@ -4,7 +4,8 @@ Status: **accepted direction of Matěj (2026-09-19). Installation of absent cont is implemented (2026-10-04, [F9 addendum](decisions.md#addendum-of-2026-10-04-content-installation-the-personalspace-and-the-presets), root decision 0188). Synchronization of existing repositories is implemented (2026-10-24, [F9 amendment](decisions.md#amendment-of-2026-10-24-the-launchpad-keeps-primary-checkouts-on-their-published-branch), -root decision 0212 points 2 and 4):** the installed Launchpad +root decision 0212 points 2 and 4; its callers extended by the [second amendment +of that day](decisions.md#second-amendment-of-2026-10-24-one-lazurio-update-composes-content-builds-and-lazurio-itself), points 3, 4 and 7):** the installed Launchpad fast-forwards the clean primary checkouts of the Organizations an Environment holds at its start and then every hour (root decision 0212 point 4), within the build sweep it already runs, and `lazurio organization sync` runs the same core explicitly ([Synchronization](#synchronization--implemented-2026-10-24)). @@ -15,20 +16,27 @@ synchronization never does. This document is the contract for both operations an their separation from product update. See [decision F9](decisions.md#f9--update-lazurio-and-synchronize-content-are-separate-operations). -## Two operations, never one +## Two operations, two cores, one `lazurio update` | | Update Lazurio | Synchronize content | | --- | --- | --- | | Subject | Installed product bytes | Organization repositories declared by an Organization | -| Contract | Product update contract (`docs/update.md`, separate PR) | This document | -| Entry | Its own command and button | Its own command and button | -| Lock | Product activation exclusion | Per-Organization content exclusion | +| Contract | [Product update](update.md) | This document | +| Entry | `lazurio update --version`, the pill; and the last part of `lazurio update` and Aktualizovat | `lazurio organization sync`, the Launchpad's hourly round and the Open of one module; and the first part of `lazurio update` and Aktualizovat | +| Lock | Product activation exclusion | Per-Folder content exclusion | | Outcome | Selected version, or preserved previous version with a reason | Per-repository result list | -Neither starts the other, and a failure of one never blocks or implies the other. -Product update must never clone a repository, create a stash, switch a branch, -regenerate preferences, upgrade tools or run a data migration. Content synchronization -must never select, download or activate a product version. +*Amended on 2026-10-24 ([F9's second amendment](decisions.md#second-amendment-of-2026-10-24-one-lazurio-update-composes-content-builds-and-lazurio-itself), root decision 0212 point 3).* +One `lazurio update` (and Nová karta's Aktualizovat, which runs the same composition) +runs both: the content first, then the Bun the moved modules pin, the builds of what +changed, then Lazurio itself +([product update, One `lazurio update`](update.md#one-lazurio-update-content-tools-builds-and-lazurio-itself)). +The two cores stay separate: each keeps its own lock and outcome, neither core starts the +other, and a failure of one never blocks or implies the other; the command reports each +part's items. Product update must never clone a repository, create a stash, switch a +branch, regenerate preferences, upgrade tools or run a data migration. Content +synchronization must never select, download or activate a product version, and the +Launchpad's own synchronization in the background never updates Lazurio. ## When it runs @@ -37,12 +45,25 @@ Synchronization of existing repositories runs in the installed Launchpad at its and then every hour (root decision 0212 point 4; one parameter, `contentSyncEveryMs`), inside the build sweep it already runs every 5 minutes (decision F58 point 7), which then builds whatever a synchronization brought. It also runs whenever a user or an authorized Task -Agent invokes `lazurio organization sync`, and later surfaces (the product update, an -"Aktualizovat" button, the Open of one module) are to call the same core, which takes -one repository and reports progress per repository. Nothing else synchronizes: no -first render, status request, health check, login, product update or installation -fetches anything, and the sweep never materializes an absent repository, which stays the -explicit installation. Read-only inspection (the doctor, a module's page) reports the +Agent invokes `lazurio organization sync`, and, since [F9's second amendment](decisions.md#second-amendment-of-2026-10-24-one-lazurio-update-composes-content-builds-and-lazurio-itself) +(root decision 0212 points 3, 4 and 7), at three more callers of the same core: + +- **`lazurio update`**, which agents run before every task: every Organization the + Environment holds, as its first part, before the builds and Lazurio itself. +- **Aktualizovat** in Nová karta: the same composition as a job of the Launchpad, with + progress per repository. +- **The Open of one module.** After the Launchpad answered a person's Start or Open, or the + gateway's `ensure` of a navigation, that module alone (`repository`: its root first, + then it and its data mounts) in the background, never before the answer: the current + build opens at once, and the build watcher builds and swaps what the synchronization + brought. At most once per module in 5 minutes (`openSyncEveryMs`), never for a module + whose checkout or Organization root the last round left blocked (the hourly round tries + those again), and nowhere the Environment keeps no content (`createOpenSync` in + `src/content/open-sync.ts`). + +Nothing else synchronizes: no first render, status request, health check, login, +`lazurio update --version` or installation fetches anything, and no caller materializes +an absent repository, which stays the explicit installation. Read-only inspection (the doctor, a module's page) reports the last recorded answer; it fetches nothing. ## Hierarchy @@ -250,12 +271,13 @@ The Launchpad runs it only when installed (`launchpad --base`), with that base's content lock, at its first sweep and then at the first sweep an hour after the last run started; a development run without a base never synchronizes. -**For later surfaces.** `syncContent` takes one repository within its Organization -(`repository`, a path relative to the root: its root is still synchronized first, then -that module and its data mounts, and only those entries of the record are replaced) and -an `onProgress` listener that hears `checking`, `fetching`, `fast-forwarding` and `done` -with the answer for each repository as it happens. The product update, an -"Aktualizovat" button and an Open of one module are to use them; none is built here. +**One repository, and progress.** `syncContent` takes one repository within its +Organization (`repository`, a path relative to the root: its root is still synchronized +first, then that module and its data mounts, and only those entries of the record are +replaced) and an `onProgress` listener that hears `checking`, `fetching` and +`fast-forwarding` (each with `to`, the commit the remote names) and `done` with the +answer for each repository as it happens. The Open of one module uses the first; +`lazurio update` and Aktualizovat the second ([When it runs](#when-it-runs)). ## Installation — implemented 2026-10-04 diff --git a/docs/decisions.md b/docs/decisions.md index d70b097d..54c65cc7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -460,7 +460,8 @@ upstream decision 0137 and requires the amendment listed above. The contract is **Accepted direction (2026-09-19); installation implemented 2026-10-04 (addendum below); synchronization of existing repositories implemented 2026-10-24 (amendment below), automatic in the Launchpad (at its start, then hourly) and -explicit as `lazurio organization sync`.** "Update Lazurio" changes product +explicit as `lazurio organization sync`; one `lazurio update` composes both cores since +the second amendment of 2026-10-24 (root decision 0212 point 3).** "Update Lazurio" changes product bytes; its contract is the product update document (`docs/update.md`, separate PR). "Synchronize content" changes Organization repositories; its contract is [content synchronization](content-sync.md). They have separate commands, buttons, @@ -892,6 +893,152 @@ and is tried again; an Organization root whose `.gitignore` does not ignore its directories is always dirty and never synchronized, which the doctor names; a public Environment's fetch still reads the checkout's own `url.*.insteadOf` (HTTPS only). +### Second amendment of 2026-10-24: one `lazurio update` composes content, builds and Lazurio itself + +**Decided by Matěj (Organization Admin) on 2026-10-24 (root decision 0212 points 3, 4, 5 +and 7, HumanAndMachines/Lazurio#625; plan DEV-6656); implemented here for v0.1.12.** +"Update Lazurio and Synchronize content are separate operations" stays true of their +cores; it is no longer true of the commands. Root decision 0212 point 3: one `lazurio +update`, with the same meaning on a computer and on an Environment, brings everything +the user owns up to date, and agents run it before every task. Contract: +[product update, One `lazurio update`](update.md#one-lazurio-update-content-tools-builds-and-lazurio-itself) +and [content synchronization, When it runs](content-sync.md#when-it-runs). + +1. **One composition, four parts.** `updateEverything` (`src/update/everything.ts`) runs + the content (`syncContent`, every Organization the Environment holds), the tools (the + Bun the apps of the module checkouts the content moved pin, point 7), the builds + (`refreshBuilt`: every built app with a build state whose checkout moved past its + build, built in the foreground and swapped in, decision F58) and Lazurio itself + (`performUpdate`), and answers an item per repository, pinned Bun, built app and the + product. Plain `lazurio update` runs it; `--version` and `--check` stay Lazurio's only, + and so does the pill. +2. **Each core keeps its lock, outcome and failure isolation.** A part that fails or + cannot run is one item and never stops the next: content is synchronized when the + release cannot be verified, Lazurio is updated when GitHub refuses a repository. The + content part waits up to 2 minutes for another content operation before it answers + `content-busy`. The exit status stays the product's: a blocked checkout or a failed + build is a finding in its item. +3. **Content and builds first, Lazurio last.** They change nothing that runs until a build + swaps, which refuses no connection; Lazurio's activation restarts the Launchpad. Last, + it never interrupts them, and the Launchpad's job has finished them before the restart. + 0212 point 3 lists Lazurio first; this order is the Platform's choice for that reason, + to be confirmed in review. +4. **Builds of what someone opened.** An app nobody opened gets no first build from an + update (its first Open builds it), and a commit whose build failed is not built again + (an explicit `lazurio module build` is), unless its pinned Bun could not be fetched, + which may pass by itself (decision F62 point 7, as the builder does). Beside a + session's apps (a computer's Launchpad on macOS), whose dependency trees their + Launchpad owns until it ends, the CLI builds nothing: the Launchpad's watcher builds + and swaps what the content brought. +5. **The Launchpad runs the same composition as a job** for Aktualizovat + (`POST`/`GET /api/update/everything`, behind the admission of every route; a click + while one runs joins it), with Lazurio last through the pill's own action, whose unit + outlives the restart it causes. Not on a public Environment, whose visitors do not own + it. +6. **The Open of one module (point 4).** After the Launchpad answered a Start or Open, or + the gateway's `ensure` of a navigation, that module's checkout is synchronized in the + background (`createOpenSync`): at most once per module in 5 minutes, never for a + checkout or root the last round left blocked, never before the answer. The build + watcher builds and swaps. The background never updates Lazurio itself (point 4), and + the pipeline keeps owning the minimum version (point 5). +7. **The tools part: the Bun the moved modules pin (DEV-6682, decision F62 point 9).** + Agreed with the DEV-6682 thread, which approved this part and decided its two open + points (one answer about the network per run, and a cache lookup after the stop) on + 2026-10-25. After the content, before any build: + - **The pins.** Those of every app the module checkouts the content fast-forwarded in + this run declare, as the commits it brought declare them (`runtimePins` of the + module core: the catalog, then `readPinnedBun` of each app's package), each once, + in order. A root, a data mount or a module that did not move adds none; when + nothing moved nothing is read. + - **The pre-warm.** `prewarmModuleRuntimes` with the module core's own resolver + (`resolveBun`, `createModuleBunResolver({ home, userBun, path, tmpdir })`), the one + its builds use, so they share its fetches under way and its cache. It is called + once per pin, so each pin's answer is a line as soon as it is known (a `fetching` + step, then the item). + - **Offline.** The first `runtime-unavailable` ends the fetches for the run: that pin + is `refused` with it. Each pin after it is only looked up without the network (the + resolver's `fetch: false`): `cached` when the user's Bun or the cache has it, + otherwise `not-fetched` with `runtime-unavailable`, never tried. + - **One run, one answer about the network.** That `runtime-unavailable`, and no other + reason, is held in memory for the rest of that `lazurio update` run only and handed + to its builds (`refreshBuilt`'s `registryUnavailable`). A build of that run whose pin + is neither the user's Bun nor in the cache refuses at once with `runtime-unavailable`, + after a local lookup only, and builds nothing; the next run builds it (point 4). A pin + that is there builds as always. A person's start, Open or build and the Launchpad's + sweep are never told: they still ask the registry themselves. + - **Other refusals** are `refused` items with their reason, and the pre-warm goes on; + `runtime-integrity-failed` (a download that did not verify, or a program that + reports another version) is `warning: true`. + - **The user's Bun.** A pin of exactly the user's Bun needs nothing: `cached`, + nothing fetched, as `prewarmModuleRuntimes` answers it. + - **Never a failed update.** Its items are findings; the exit status stays the + product's, and pins that cannot be read are one `failed` item of the part, after the + pins that were read are pre-warmed. Only a package that was read and pins nothing + adds nothing; a package.json missing, unreadable or pinning something other than an + exact Bun, and a moved module the catalog does not declare, count as unreadable + (the review of #622), so an update never looks clean while it skipped one. + - **Only what moved.** Apps with a build state whose checkout did not move are not + pre-warmed, even when their pin is not cached: a pin changes only with a commit, + and the builds' own preparation resolves each app's pin through the same resolver + before its build script runs (F62 point 6), so no build ever runs without its Bun. + An app whose build is current needs no Bun of its own until it starts its own + server. + - **On a computer's session Launchpad** the CLI builds nothing (point 4), but its + tools part fetches into the cache the Launchpad's watcher then reads; a build that + starts during the fetch waits for it under the version's exclusion (F62 point 3). + +| Alternative | Trade-off / disposition | +| --- | --- | +| Keep two commands, `lazurio update` and `lazurio organization sync`, and have agents run both | The decision asks for one command with one meaning; two commands are two things to remember before every task; rejected | +| One transaction over product and content | One lock would let a stuck fetch block a product update and the reverse; their failure isolation is the reason F9 kept them apart; rejected | +| Lazurio first, then content and builds (the decision's listing) | The activation restarts the Launchpad, which would end the Launchpad's job before its content and builds, and the CLI's builds would race the restarted Launchpad's first sweep; the order only matters for a release that builds differently, which its own first sweep then does; rejected for content first | +| The CLI re-executes the new version for content and builds after an activation | A second process and protocol for a rare case the next hourly round covers; rejected | +| Build every built app of the Organization, opened or not | A first build installs a module's dependencies and can take minutes; agents run the update before every task; the Open builds what someone uses; rejected | +| Rebuild a commit whose build failed at every update | Agents would pay for the same failure before every task; an explicit build does it; rejected | +| Change the JSON to a new top-level shape | Every reader of `lazurio update --json` (the Launchpad's activators, scripts, agents) reads the product's answer; `items` beside it keeps them; rejected | +| The Aktualizovat button starts the CLI in a transient unit and reads its progress from a file | Survives the restart, but adds a progress file and protocol; the Launchpad already runs both cores in its process, and the product's own unit already outlives the restart; rejected | +| Synchronize at every Open, without a debounce | The gateway asks `ensure` at every navigation; a burst would be a fetch per request; rejected for once per 5 minutes per module | +| Pre-warm the pin of every app with a build state, or of every app, at each update | Agents run the update before every task; about 70 MB per version nothing in the run needs, and the builds resolve their own pins anyway; rejected for the apps of what moved | +| One `prewarmModuleRuntimes` call with every pin | Its answer comes when all pins are done, so a page would show the first pin under way while the last fetches, and stopping after the first `runtime-unavailable` would need a wrapper around the resolver; rejected for one call per pin | +| Keep fetching the other pins when one is `runtime-unavailable` | Offline, every pin waits for the registry in turn, up to the 5-minute bound each; rejected with the DEV-6682 thread for the first one ending the pre-warm | +| Let the run's builds ask the registry themselves after the pre-warm found it silent | Where the network drops packets, each build waits up to the fetch bound for an answer the run already has; rejected with the DEV-6682 thread for one answer per run | +| Keep the offline answer across runs (a file, or the Launchpad's memory) | A network that is back would still be refused until something forgot the answer; rejected for memory of one run | +| Report every pin after the stop `not-fetched` without looking | A pin the user's Bun or the cache already has would read as missing; rejected with the DEV-6682 thread for a lookup without the network | +| A failed pre-warm fails the update | A Bun that cannot be had is a finding of the apps that pin it, which their start or build names as well; agents run the update before every task; rejected | + +Tests: `tests/update-everything.test.ts` (the order and each line's steps; a failing +product keeping the content's answers and the reverse; the wait for a busy content lock; +the tools part with the resolver a start uses and a fake installer: a pin not cached +fetched before the build, only for the checkouts that moved, then from the cache; offline +stopping at the first `runtime-unavailable`, the rest looked up without the network +(`cached` or `not-fetched`); the builds of that run told so, by `runtime-unavailable` +alone and never in the next run; each other refusal an item and integrity a warning; the +user's own Bun not fetched; an update that never fails because of tools, pins that cannot +be read making one failed item after the others are pre-warmed; the job that a second +start joins; the CLI's words; the Open: exactly that module, +debounced per module, never blocking, never for a blocked checkout or root; the module +an answer names), `tests/module-built.test.ts` (on real git and real builds: `lazurio +update --json` with no newer Lazurio and a module behind fast-forwards it, lists every +item with Lazurio last, and the Launchpad swaps in the new build; the Launchpad's own run +builds and swaps at once, does not build a failed commit again and gives an app nobody +opened no first build; a failing Lazurio still answers the content; the tools part fetching +the Bun a moved module's new commit pins before its build, which runs with it; offline, +the run's build refusing at once with no second attempt at the registry, a person's build +still asking it, a later run building it, and a pin in the cache building under the +offline answer; `runtimePins` reading each pin once, a package without one adding nothing, +and a pin that cannot be read or a moved module not declared counted), +`tests/bun-runtime.test.ts` (the lookup without the network: the user's +Bun or the cache, otherwise `runtime-unavailable` at once, never the installer, no wait +for a fetch under way) and +`tests/launchpad-update.test.ts` (the routes behind the admission, a joined second +click, Lazurio last through the pill; the pill's part when nothing is newer or the check +fails). Risks: the Launchpad's job goes with the restart Lazurio's activation causes, so +its lines after it are those the page last read; on a computer's session Launchpad the +CLI reports no build items; a build of a big app holds an update for its minutes, which is +the price of a build that is ready when someone opens it. Offline, the first pin still waits +for the registry up to the fetch bound where the network drops packets instead of refusing +them; everything after it in the run answers from what is on the disk. + ## F10 — Workspace presets and typed owner requests **Accepted direction (2026-09-19); amended and accepted by Matěj 2026-09-22; @@ -18803,9 +18950,12 @@ which broke the same App for their colleagues. per pin, `cached`, `fetched` or `refused` with the reason a start would give, never a throw; the caller reads each App's pin (`readPinnedBun`). Root decision 0212 (HumanAndMachines/Lazurio#625, in review) has `lazurio update` run it after content - synchronization and before the preparation and builds of what moved. It is not wired - here: the composition of `lazurio update` is on plan DEV-6656's branch, with a no-op - tools step as its seam, and whichever change merges second plugs it in. + synchronization and before the preparation and builds of what moved: its tools part + does so since v0.1.12 ([F9's second amendment](#second-amendment-of-2026-10-24-one-lazurio-update-composes-content-builds-and-lazurio-itself), + point 7). Once its pre-warm meets `runtime-unavailable`, the rest of that one run + resolves with `fetch: false`, a lookup of the user's Bun and the cache that refuses + `runtime-unavailable` at once, for its remaining pins and its builds, while a person's + start, Open or build outside the run still fetches. 10. **`lazurio tools update bun` needs no guard.** The user's Bun no longer decides whether a pinned App starts: it runs one only when it is exactly the pinned version, and otherwise only installs (`bun add` with flags every Bun 1.x has). What still diff --git a/docs/update.md b/docs/update.md index 3d9529f3..e4ea90cb 100644 --- a/docs/update.md +++ b/docs/update.md @@ -48,15 +48,19 @@ release that publishes every platform at once. - **One core, one command.** CLI and Launchpad run the same `lazurio update`. There is no second updater, no worker entrypoint and no installer logic - outside the product. + outside the product. Since root decision 0212 point 3 the plain command also + brings the content and the builds up to date before it updates Lazurio itself + ([One `lazurio update`](#one-lazurio-update-content-tools-builds-and-lazurio-itself)); each + part keeps its own core, lock and outcome. - **The user owns the version.** The user of the Environment runs `lazurio update`, on a hosted Machine as on their own computer. A provider's pin is a minimum: a rollout installs a missing or broken installation and may raise one below the pin through the offline update, and never lowers a version ([F17 addendum 2026-09-28](decisions.md#f17--operator-tools-belong-to-the-operator-the-rollout-pins-the-baseline-and-repairs)). - **Explicit activation.** Checking and showing availability are automatic; - download and activation start only from `lazurio update` or the Launchpad - action. + download and activation start only from `lazurio update` or a Launchpad + action (the pill, or Nová karta's Aktualizovat, which runs the same + composition); the Launchpad's background work never starts them. - **No wedge.** Every failure before the switch leaves the installed product as it was and the next attempt possible. Downloads are scratch files; there is no pending download, no journal and no activation marker. @@ -838,6 +842,143 @@ if the new release is unhealthy, which its Recovery mode (`503` on the health socket) makes the correct decision. From the first mutating command the new release runs, the migration removes all of it. +## One `lazurio update`: content, tools, builds and Lazurio itself + +Root decision 0212 point 3 (Matěj, 2026-10-24; [F9's second amendment of +2026-10-24](decisions.md#second-amendment-of-2026-10-24-one-lazurio-update-composes-content-builds-and-lazurio-itself)). +`lazurio update` has the same meaning on a computer and on an Environment: it brings +everything the user owns up to date. Agents run it before every task; Nová karta's +Aktualizovat button runs it by hand. One composition (`updateEverything` in +`src/update/everything.ts`) runs these parts, in this order: + +1. **Content.** The clean primary checkouts of the Organizations the Environment holds go + to their published branch: the same core, lock and record as the Launchpad's hourly + round and `lazurio organization sync` ([content synchronization](content-sync.md#synchronization--implemented-2026-10-24)). + When another content operation holds the Folder's lock (the hourly round, an + installation) it asks again every 2 seconds for up to 2 minutes, then answers + `content-busy`. +2. **Tools**: the Bun each app pins (`packageManager: bun@x.y.z`, decision F62, root + decision 0171 addendum of 2026-10-24; plan DEV-6682), before any build, so a build + never waits for it. The pins are those of every app the module checkouts the content + part fast-forwarded in this run declare, read from the commits it brought, each pin + once (`runtimePins` in the module core). Each is made available with the resolver a + start uses (`prewarmModuleRuntimes` with the module core's `resolveBun`): the user's + Bun when it is that version, the runtime cache, or a verified fetch into it. One pin + at a time: + - a pin there already, the user's own Bun included, is `cached`, and nothing is + fetched; a fetched one is `fetched`; + - **offline**: the first `runtime-unavailable` (the registry did not answer) ends the + fetches for the run. That pin is `refused` with that reason. Every pin after it is + only looked up without the network: `cached` when the user's Bun or the cache has it, + otherwise `not-fetched` with the same reason, never tried. That one answer about the + network, and no other refusal, holds for the builds of the same run (below), in + memory and never for the next run; + - every other refusal is `refused` with the reason a start would give + (`runtime-version-unknown`, `runtime-disk-full`, `runtime-unsupported-platform`, + `operation-failed`), and the pre-warm goes on. `runtime-integrity-failed`, a + download that did not verify or a program that reports another version, is also + `warning: true`: it was discarded, never used; + - the pre-warm never fails the update: its items are findings, and the exit status + stays the product's. A pin that cannot be read (a moved app's package.json missing, + unreadable or pinning something other than an exact Bun, or a moved module the + catalog does not declare) makes the part one `failed` item, after the pins that were + read are pre-warmed; only a package that pins nothing adds nothing. + + When nothing moved, nothing is read. Apps whose checkout this run did not move are not + pre-warmed, even those with a build state whose pin is not cached: a pin changes only + with a commit, and this run's commits are those the content brought; the build of any + other app resolves its own pin first through the same resolver (F62 point 6), and an + app whose build is current needs no Bun of its own until it starts its own server. + Pre-warming every opened app's pin would fetch about 70 MB per version nothing in this + run asked for. +3. **Builds.** Every built app with a build state (someone opened it once) whose checkout + moved past its build is built in the foreground and swapped in where it runs (decision + F58's refresh, `refreshBuilt` in the module core). A commit whose last build failed is + not built again here (an explicit `lazurio module build` does that), unless what failed + may pass by itself: its pinned Bun could not be fetched (`runtime-unavailable`, + `runtime-disk-full`, F62 point 7). An app nobody opened gets no first build: its first + Open builds it. After the tools part found the registry not answering, a build whose + pinned Bun is neither the user's nor in the cache refuses at once with + `runtime-unavailable` (a local lookup, no second attempt at the registry) and the next + run builds it; a person's start, Open or build outside the run still asks the + registry. On a computer whose Launchpad runs its apps in its own session + (macOS), that Launchpad owns their dependency trees until it ends, so the CLI leaves + their builds to it: its build watcher builds and swaps what the content brought within + seconds, or at its next start, and finds the Bun the CLI's tools part fetched in the + runtime cache they share (a build that starts while that fetch is under way waits for + it under the version's exclusion, F62 point 3). +4. **Lazurio itself**, as the rest of this document describes, last. + +**Why this order.** Content, tools and builds change nothing that runs until a build swaps +(a fetched Bun is a new directory of the runtime cache), and a swap refuses no connection +(F58); the product's activation restarts the Launchpad. Run +last, a restart never interrupts the other parts, and in the Launchpad the job has done +everything else before it goes down with the restart. A new Lazurio that would build +differently does so at its own first sweep. The decision lists the parts with Lazurio +first; the order is the Platform's choice for these reasons. + +**Isolation.** A part that fails, or cannot run here, is one item of the answer and never +stops the next: content is synchronized when the release cannot be verified, and Lazurio +is updated when GitHub refuses a repository. + +**The answer.** With `--json`, the product's own answer as before (`updated`, +`up-to-date` or `error`, with every key this document names) gains `items`, one per +repository, pinned Bun, built app and the product, in the order the parts gave them: + +```json +{"kind":"up-to-date","running":"0.1.12","latest":"0.1.12","folderRefresh":null, + "items":[ + {"part":"content","organization":"Alpha","root":"organizations/alpha","path":".","kind":"root","branch":"main","result":"current","at":"…"}, + {"part":"content","organization":"Alpha","root":"organizations/alpha","path":"workspace/site","kind":"module","branch":"main","result":"fast-forwarded","from":"…","to":"…","at":"…"}, + {"part":"tools","packageManager":"bun@1.4.3","result":"fetched"}, + {"part":"build","organization":"alpha","module":"site","app":"app/package.json","result":"built","commit":"…"}, + {"part":"product","result":"current","version":"0.1.12"}]} +``` + +A content item is a repository's answer of the synchronization (`fast-forwarded`, +`current`, `blocked` with its reason, `absent`, `denied`, `unavailable`, `failed`), or the +part as a whole: `{"part":"content","result":"not-run","reason"}` with `folder-unreadable`, +`not-for-this-environment`, `content-busy` or `not-installed`. A tools item is a pinned +Bun by its pin (`packageManager`): `cached`, `fetched`, `refused` with its reason (and +`warning: true` for `runtime-integrity-failed`) or `not-fetched` with +`runtime-unavailable`. A +build item is `built`, `current` or `failed` with the build's own reason; what runs stays +as it was. The product item is `current`, `updated` (`from`, `to`), `started` (the +Launchpad's run, below), `failed` with its code, or `not-run`. A part that threw is +`{"part","result":"failed","reason":"operation-failed"}`. Without `--json` the lines of a +repository that moved or is left as it is, a summary of the content, a line per pinned +Bun fetched or not (a warning first for one that did not verify; nothing for one that was +there already), a line per app built or not built, then the product's own lines as before. The exit status is the product's, +as before: a blocked checkout, a pinned Bun that could not be had or a failed build is a +finding in its item, never a failed command. Content, tools and builds need the Folder (`--folder`, the supervised unit's, or in a +Remote Environment the declared operator's); without one only Lazurio is updated and its +answer is exactly as before, without `items`. `lazurio update --version ` and `--check` name Lazurio only, +as does the Launchpad's pill. + +**In the Launchpad.** `POST /api/update/everything` with `{}` starts the same composition +as a job of the installed Launchpad, behind the admission of every `/api/*` route (the +local token, or the gateway's session and the same origin for a write), and answers `202 +{kind: "update-everything", job, joined}`; while a run is under way it answers that run +with `joined: true`, so a second click joins it. `GET /api/update/everything` answers the +newest run, `{kind, job: {id, state: "running" | "succeeded", lines} | null}`, where each +line is an item's step while under way (`checking`, `fetching` and `fast-forwarding` with +the commit it moves to, a pin's `fetching` while its Bun is made available, `building` +with its commit, Lazurio's `checking`) and then its answer. Its content part is the +Launchpad's own synchronization, its tools part pre-warms with the Launchpad's own +resolver, whose fetches its builds share, its builds are swapped in by the Launchpad at +once, and Lazurio is updated through the pill's own action, which +starts `lazurio update --version` in the unit that outlives the restart it causes +(`result: "started"`). The job lives in the Launchpad's memory and ends with it; what the +run did stays in its owners' records (the content's record, the builds' states, the +selector). A Launchpad that is not an installed one answers `503 update-unavailable`; a +public Environment, whose visitors do not own it, `404`. + +**In the background, never Lazurio itself.** The Launchpad's own content and builds every +hour and at the Open of one module ([content synchronization](content-sync.md#when-it-runs)) +never check, download or activate a product version: that stays this explicit command. +The pipeline owns the minimum version (a pin is a minimum, F17's addendum of 2026-09-28); +`lazurio update` takes Lazurio above it (root decision 0212 point 5). + ## Surfaces - **Launchpad.** Poller: first check shortly after start, then every few minutes @@ -856,7 +997,7 @@ release runs, the migration removes all of it. update and with `state-invalid`; "up to date" and a running check show nothing (Matěj 2026-09-28, as in T3 Code). The Folder refresh line below is independent of the pill. -- **CLI.** `lazurio update`, `--check`, `--version `, `update status +- **CLI.** `lazurio update` (everything, [above](#one-lazurio-update-content-tools-builds-and-lazurio-itself)), `--check`, `--version `, `update status [--json]`, `--folder ` on `update` and `update status`, `lazurio install [--verify-release ] [--service systemd-user]` (from a newer executable over an existing installation: the offline diff --git a/src/content/cli.ts b/src/content/cli.ts index c74008f4..0f2baad0 100644 --- a/src/content/cli.ts +++ b/src/content/cli.ts @@ -104,7 +104,8 @@ usage or not allowed on this Environment. organization sync [] [--folder ] [--json] Keeps the primary checkouts of that Organization (without a login, of every Organization this Environment holds) on their published branch, as the - Launchpad does by itself at its start and then hourly: the root first, then, from the + Launchpad does by itself at its start and then hourly, and as lazurio update + does first (before the builds and Lazurio itself): the root first, then, from the root's declaration as it is after that, its workspace modules, root-level applications and their declared data mounts (on the mount's declared branch) that are in the Folder. Per repository one ls-remote of its branch, diff --git a/src/content/open-sync.ts b/src/content/open-sync.ts new file mode 100644 index 00000000..e622d8e4 --- /dev/null +++ b/src/content/open-sync.ts @@ -0,0 +1,109 @@ +import type { Catalog } from "../organizations/catalog"; +import type { SyncEntry, SyncRequest, SyncRun } from "./sync"; + +// The Open of one module (root decision 0212 point 4, decision F9's second +// amendment of 2026-10-24): after the Launchpad answered a person's Start or +// Open, or the gateway's `ensure` of a navigation, the module's own checkout +// is kept on its published branch in the background, so that a publication +// reaches it without waiting for the hourly round. The answer never waits +// for it: the current build opens at once, and a newer commit the +// synchronization brings is built and swapped in by the Launchpad's build +// watcher (decision F58 point 7). At most once per module within +// `openSyncEveryMs`, and never for a module whose checkout, or whose +// Organization root, the last synchronization left blocked: the hourly round +// tries those again. The same core (`syncContent` with `repository`), the same +// content lock and record; nothing where this Environment keeps no content +// (the core answers `not-allowed` before it reads anything). + +/** One Open per module and 5 minutes at most. */ +export const openSyncEveryMs = 5 * 60_000; + +/** A module of an Organization, as the synchronization names it. */ +export type OpenedModule = Readonly<{ + /** The Organization's GitHub login. */ + organization: string; + /** Its root, relative to the Folder (`organizations/`). */ + root: string; + /** The module, relative to the root (`workspace/`). */ + path: string; +}>; + +/** The module an answer of the module routes names (its Organization's slug + * and its id), as the synchronization names it: through the catalog, by the + * Organization's GitHub login. Null for the Personalspace, an Organization + * without a GitHub binding, or a slug more than one root claims. */ +export function openedModule( + catalog: Catalog, + answer: Readonly<{ organization: string; module: string }>, +): OpenedModule | null { + const groups = catalog.organizations.filter( + (group) => group.organization === answer.organization, + ); + const [group] = groups; + if (groups.length !== 1 || group?.forgeLogin === undefined) return null; + const module = group.modules.find((entry) => entry.module === answer.module); + return module === undefined + ? null + : Object.freeze({ + organization: group.forgeLogin, + root: `organizations/${group.directory}`, + path: module.path, + }); +} + +export function createOpenSync( + input: Readonly<{ + sync: (request: SyncRequest) => Promise; + /** The last recorded answers (`readSyncRecord`); null when none. */ + record: () => Promise; + /** Test seams. */ + now?: () => number; + everyMs?: number; + }>, +) { + const now = input.now ?? Date.now; + const everyMs = input.everyMs ?? openSyncEveryMs; + const last = new Map(); + const running = new Set>(); + + const blocked = ( + entries: readonly SyncEntry[] | null, + module: OpenedModule, + ) => + entries?.some( + (entry) => + entry.result === "blocked" && + entry.organization.toLowerCase() === + module.organization.toLowerCase() && + entry.root === module.root && + (entry.path === "." || entry.path === module.path), + ) === true; + + return Object.freeze({ + /** The module was opened: its synchronization in the background, when + * due. Returns at once. */ + opened(module: OpenedModule): void { + const key = `${module.organization.toLowerCase()}\0${module.root}\0${module.path}`; + const at = now(); + const before = last.get(key); + if (before !== undefined && at - before < everyMs) return; + last.set(key, at); + const work = (async () => { + if (blocked(await input.record().catch(() => null), module)) return; + await input.sync({ + organization: module.organization, + repository: module.path, + }); + })() + .catch(() => undefined) + .finally(() => running.delete(work)); + running.add(work); + }, + /** Resolves when no synchronization of an Open runs (tests, shutdown). */ + async settled(): Promise { + while (running.size > 0) await Promise.all([...running]); + }, + }); +} + +export type OpenSync = ReturnType; diff --git a/src/content/sync.ts b/src/content/sync.ts index 6b016f9d..5e66c806 100644 --- a/src/content/sync.ts +++ b/src/content/sync.ts @@ -42,11 +42,12 @@ export { // Production Space repositories, `infra` and the Personalspace are never // touched. The Launchpad runs it at its start and then every hour // (`contentSyncEveryMs`) within its build sweep -// (`keepBuilt`), the CLI as `lazurio organization sync`: one core, one content -// lock, one record; a later surface (the product update, an "Aktualizovat" -// button, an Open) calls the same core, for one repository too -// (`SyncRequest.repository`) and with progress per repository -// (`onProgress`). +// (`keepBuilt`), the CLI as `lazurio organization sync`, and since decision +// F9's second amendment of 2026-10-24 (root decision 0212 points 3, 4 and 7) +// `lazurio update` and Nová karta's Aktualizovat (`updateEverything`, with +// progress per repository, `onProgress`) and the Open of one module +// (`createOpenSync`, one repository, `SyncRequest.repository`): one core, one +// content lock, one record. export type SyncRun = | Readonly<{ @@ -79,6 +80,9 @@ export type SyncProgress = Readonly<{ kind: SyncEntry["kind"]; branch: string; step: "checking" | "fetching" | "fast-forwarding" | "done"; + /** `fetching` and `fast-forwarding`: the commit the remote's branch + * names, the one the checkout is to move to. */ + to?: string; /** `done`: the answer. */ entry?: SyncEntry; }>; @@ -210,28 +214,27 @@ export async function syncContent( path: string, kind: SyncEntry["kind"], branch: string, - run: (progress: (step: CheckoutStep) => void) => Promise, + run: ( + progress: (step: CheckoutStep, to: string) => void, + ) => Promise, ): Promise => { const where = { organization: login, root, path, kind, branch }; - const report = (step: SyncProgress["step"], entry?: SyncEntry) => { + const report = ( + step: SyncProgress["step"], + extra: Readonly<{ to?: string; entry?: SyncEntry }> = {}, + ) => { try { - request.onProgress?.( - Object.freeze({ - ...where, - step, - ...(entry === undefined ? {} : { entry }), - }), - ); + request.onProgress?.(Object.freeze({ ...where, step, ...extra })); } catch {} }; report("checking"); - const outcome = await run(report); + const outcome = await run((step, to) => report(step, { to })); const entry: SyncEntry = Object.freeze({ ...where, ...outcome, at: now().toISOString(), }); - report("done", entry); + report("done", { entry }); return entry; }; const directory = join(folder, root); @@ -409,7 +412,7 @@ async function syncCheckout( directory: string, expected: (repository: string) => boolean, branch: string, - progress: (step: CheckoutStep) => void = () => {}, + progress: (step: CheckoutStep, to: string) => void = () => {}, ): Promise { const { git } = context; const deadline = Date.now() + context.timeoutMs; @@ -446,7 +449,7 @@ async function syncCheckout( // Known here already: ahead or diverged is decided without a fetch. const known = await git.relation(directory, observed.commit, head.commit); if (known === "ahead" || known === "diverged") return blocked(known); - progress("fetching"); + progress("fetching", head.commit); const fetched = await git.fetchBranch( directory, remote, @@ -472,7 +475,7 @@ async function syncCheckout( return blocked(relation); if (relation === null) return { result: "failed", reason: "relation-unknown" }; - progress("fast-forwarding"); + progress("fast-forwarding", fetched.commit); return (await git.fastForward(directory, fetched.commit)) ? { result: "fast-forwarded", from: now.commit, to: fetched.commit } : { result: "failed", reason: "fast-forward-failed" }; diff --git a/src/launchpad/server.ts b/src/launchpad/server.ts index 3c08600b..5a70be09 100644 --- a/src/launchpad/server.ts +++ b/src/launchpad/server.ts @@ -31,7 +31,8 @@ import { processContentHost, processGitHubHost, } from "../content/host"; -import { syncContent } from "../content/sync"; +import { createOpenSync, openedModule } from "../content/open-sync"; +import { readSyncRecord, type SyncRequest, syncContent } from "../content/sync"; import type { ExecutorHost } from "../executor/flow"; import { processExecutorHost } from "../executor/host"; import { type DocumentsHost, processDocumentsHost } from "../files/documents"; @@ -122,6 +123,12 @@ import { createSshProofs } from "../tools/ssh-key"; import { resolveOnPath } from "../tools/status"; import type { GithubAction } from "../tools/team-github"; import { toolsUpdate } from "../tools/update"; +import { + contentNotRun, + createUpdateJobs, + productNotInstalled, + updateEverything, +} from "../update/everything"; import { embeddedIdentity } from "../update/identity"; import { runProcess } from "../update/self-check"; import type { VaultHost } from "../vault/flow"; @@ -765,27 +772,86 @@ export async function startLaunchpad( // (`contentSyncEveryMs`), within the sweep that builds what moved. Its // content lock and record are the install base's, as the CLI's; a // development run without a base never synchronizes. - const syncCheckouts = + const syncHost = installed === undefined ? undefined - : () => - syncContent( - folder, - contentHost ?? { - ...processGitHubHost({ - env: process.env, - platform: toolsEnvironment.platform, - tools: toolsEnvironment, - }), - lockDirectory: contentLockDirectory(installed.base), - }, - ); + : (contentHost ?? { + ...processGitHubHost({ + env: process.env, + platform: toolsEnvironment.platform, + tools: toolsEnvironment, + }), + lockDirectory: contentLockDirectory(installed.base), + }); + const syncCheckouts = + syncHost === undefined + ? undefined + : (request?: SyncRequest) => syncContent(folder, syncHost, request); // A static app someone opened is built again after a pull (decision F58); // an app nobody uses falls asleep (root decision 0195 point 23). modules.keepBuilt({ running, - ...(syncCheckouts === undefined ? {} : { sync: syncCheckouts }), + ...(syncCheckouts === undefined ? {} : { sync: () => syncCheckouts() }), }); + // The Open of one module keeps its checkout on its published branch in the + // background (root decision 0212 point 4): after the answer to a person's + // Start or Open, or to the gateway's `ensure` of a navigation, at most once + // per module in 5 minutes and never for a checkout the last round left + // blocked. The build watcher builds and swaps what it brings. + const openSync = + syncHost === undefined || syncCheckouts === undefined + ? undefined + : createOpenSync({ + sync: syncCheckouts, + record: () => readSyncRecord(syncHost.lockDirectory, folder), + }); + const opened = (answer: ModuleAnswer | ModuleBlocked) => { + if (openSync === undefined || answer.kind !== "module") return; + void catalog + .read() + .then((read) => { + const module = openedModule(read, answer); + if (module !== null) openSync.opened(module); + }) + .catch(() => undefined); + }; + // One `lazurio update` from Nová karta's Aktualizovat (root decision 0212 + // points 3 and 7): the CLI's composition, run here as a job: the content, + // the Bun the moved modules pin, with this Launchpad's own resolver + // (decision F62), the builds of what changed (swapped in by this + // Launchpad), and Lazurio itself last through the pill, whose unit + // outlives the restart it causes. + // Only on an installed Launchpad, and never on a public Environment, whose + // visitors do not own it. + const updates = + installed === undefined || publicEnvironment + ? undefined + : createUpdateJobs({ + run: (onChange) => + updateEverything( + { + content: async (onProgress) => + syncCheckouts === undefined + ? contentNotRun("not-installed") + : syncCheckouts({ onProgress }), + tools: { + pins: (moved) => modules.runtimePins(moved), + resolve: modules.resolveBun, + }, + builds: (onProgress, network) => + modules.refreshBuilt(onProgress, network), + product: async () => + pill === undefined + ? { + part: "product", + result: "not-run", + reason: productNotInstalled, + } + : pill.updateNow(), + }, + onChange, + ), + }); // The Folder's catalog, kept while nothing it depends on changed // (catalog-reading.ts): the page's boot document, the shell document, the // catalog and Apps' questions about one Organization read it. In Služby @@ -1334,6 +1400,8 @@ export async function startLaunchpad( ? {} : { waitMs: hostedOptions.ensureWaitMs }), }); + // A navigation's `ensure` is an Open (root decision 0212 point 4). + if (mayStart) opened(result); const status = ensureStatus(result); return status === 204 ? new Response(null, { status, headers }) @@ -1815,6 +1883,20 @@ export async function startLaunchpad( return response({ error: "operation-failed" }, 500); } } + if ( + request.method === "GET" && + url.pathname === "/api/update/everything" + ) { + // The newest run of Aktualizovat, running or ended (root decision + // 0212 point 7): what Nová karta draws and joins. + if (closing) return response({ error: "closing" }, 503); + if (publicEnvironment) return response({ error: "not-found" }, 404); + if (!updates) return response({ error: "update-unavailable" }, 503); + return response({ + kind: "update-everything", + job: updates.latest(), + }); + } if (request.method === "GET" && url.pathname === "/api/update/status") { if (closing) return response({ error: "closing" }, 503); if (!pill) return response({ error: "update-unavailable" }, 503); @@ -1924,6 +2006,8 @@ export async function startLaunchpad( : moduleRequest[3] === "stop" ? await modules.stop(name, options) : await modules.status(name, options); + // A person's Start or Open (root decision 0212 point 4). + if (moduleRequest[3] === "start") opened(result); return response( result, result.kind === "blocked" @@ -2083,6 +2167,17 @@ export async function startLaunchpad( if (closing) return response({ error: "closing" }, 503); changesTools = toolsChangePaths.has(url.pathname); if (changesTools) toolsReading.forget(); + if (url.pathname === "/api/update/everything") { + // Aktualizovat: one `lazurio update` (root decision 0212 points 3 + // and 7), or the run already under way, which the click joins. + stateFields(input, []); + if (publicEnvironment) return response({ error: "not-found" }, 404); + if (!updates) return response({ error: "update-unavailable" }, 503); + return response( + { kind: "update-everything", ...updates.start() }, + 202, + ); + } if (url.pathname === "/api/update/apply") { if (!pill) return response({ error: "update-unavailable" }, 503); const value = stateFields(input, ["version"]); diff --git a/src/launchpad/update-pill.ts b/src/launchpad/update-pill.ts index ddd236fa..dcc8aee9 100644 --- a/src/launchpad/update-pill.ts +++ b/src/launchpad/update-pill.ts @@ -1,5 +1,6 @@ import type { FolderRefresh } from "../folder/refresh-needed"; import { type UpdateError, UpdateFailure, updateError } from "../update/errors"; +import type { ProductItem } from "../update/everything"; import { isProductVersion } from "../update/identity"; import type { Activation, Activator } from "../update/launchpad-activation"; import { @@ -233,6 +234,11 @@ export type UpdatePill = Readonly<{ stop(): void; status(): Promise; apply(version: string): Promise; + /** The product part of one `lazurio update` from the Launchpad (root + * decision 0212 point 3): a check now, and when it verifies a newer + * release, the pill's own action for it, which starts `lazurio update` + * in the unit that outlives the restart it causes. */ + updateNow(): Promise; }>; export function createUpdatePill(input: { @@ -247,6 +253,35 @@ export function createUpdatePill(input: { () => checkForUpdate(environment), input.poller, ); + const apply = async (version: string): Promise => { + const failed = (failure: UpdateError): ApplyResult => + Object.freeze({ kind: "error" as const, ...failure }); + if (!isProductVersion(version)) + return failed(updateError("internal", { reason: "version" })); + const status = await readStatus(environment); + if (status.stateInvalid !== null) + return failed( + updateError("state-invalid", { path: status.stateInvalid }), + ); + const latest = status.lastCheck?.latest ?? null; + if (latest !== version) { + // The click meant what it saw; a fresh check shows what is true now. + void poller.checkNow(); + return Object.freeze({ kind: "stale" as const, version, latest }); + } + if ((await activator.observe()).inFlight) + return failed(updateError("busy")); + try { + await activator.start(version); + } catch (error) { + return failed( + error instanceof UpdateFailure + ? error.failure + : updateError("internal", { stage: "start" }), + ); + } + return Object.freeze({ kind: "started" as const, version }); + }; return Object.freeze({ start: poller.start, stop: poller.stop, @@ -259,34 +294,29 @@ export function createUpdatePill(input: { now: now(), }); }, - async apply(version) { - const failed = (failure: UpdateError): ApplyResult => - Object.freeze({ kind: "error" as const, ...failure }); - if (!isProductVersion(version)) - return failed(updateError("internal", { reason: "version" })); - const status = await readStatus(environment); - if (status.stateInvalid !== null) - return failed( - updateError("state-invalid", { path: status.stateInvalid }), - ); - const latest = status.lastCheck?.latest ?? null; - if (latest !== version) { - // The click meant what it saw; a fresh check shows what is true now. - void poller.checkNow(); - return Object.freeze({ kind: "stale" as const, version, latest }); - } - if ((await activator.observe()).inFlight) - return failed(updateError("busy")); - try { - await activator.start(version); - } catch (error) { - return failed( - error instanceof UpdateFailure - ? error.failure - : updateError("internal", { stage: "start" }), - ); - } - return Object.freeze({ kind: "started" as const, version }); + apply, + async updateNow() { + const failed = (code: string): ProductItem => + Object.freeze({ part: "product", result: "failed", code }); + const checked = await poller.checkNow(); + if (checked.kind === "error") return failed(checked.code); + if (checked.kind === "up-to-date") + return Object.freeze({ + part: "product", + result: "current", + version: checked.running, + }); + let applied = await apply(checked.latest); + // A check that another one overtook names the newer release: once. + if (applied.kind === "stale" && applied.latest !== null) + applied = await apply(applied.latest); + if (applied.kind === "started") + return Object.freeze({ + part: "product", + result: "started", + to: applied.version, + }); + return failed(applied.kind === "stale" ? "internal" : applied.code); }, }); } diff --git a/src/modules/bun-runtime.ts b/src/modules/bun-runtime.ts index c4d7929f..ec5343bf 100644 --- a/src/modules/bun-runtime.ts +++ b/src/modules/bun-runtime.ts @@ -65,9 +65,16 @@ export type ModuleBun = Readonly<{ /** The Bun for the package in `owner` that pins `packageManager` (or none). * Throws `PreparationRefused` with a `runtime-*` reason, named by that - * package.json. */ + * package.json. With `fetch: false` a lookup without the network: the user's + * Bun or the runtime cache as it is, otherwise `runtime-unavailable` at once + * (one `lazurio update` run after its pre-warm found the registry not + * answering, decision F62 point 9). */ export type ModuleBunResolver = ( - input: Readonly<{ packageManager: string | null; owner: string }>, + input: Readonly<{ + packageManager: string | null; + owner: string; + fetch?: boolean; + }>, ) => Promise; export type RuntimeReason = Extract; @@ -611,7 +618,7 @@ export function createModuleBunResolver(options: { } } - return async ({ packageManager, owner }) => { + return async ({ packageManager, owner, fetch: mayFetch = true }) => { if (packageManager === null) return user; const match = /^bun@(\d+\.\d+\.\d+)$/.exec(packageManager); if (match === null) throw new Error("Exact Bun version required"); @@ -626,6 +633,14 @@ export function createModuleBunResolver(options: { ); const targets = bunTargets(await system(), version); if (targets === null) throw refusal("runtime-unsupported-platform"); + // Without the network: the cache as it is, read without the version's + // exclusion (a fetch under way elsewhere is not waited for); a version + // absent or broken there is refused as a registry that did not answer. + if (!mayFetch) { + const there = await cached(join(root, version), version, targets); + if (there.kind === "ready") return there.bun; + throw refusal("runtime-unavailable"); + } const key = join(root, version); let pending = fetches.get(key); if (pending === undefined) { diff --git a/src/modules/module-operations.ts b/src/modules/module-operations.ts index 5a4d683c..457168df 100644 --- a/src/modules/module-operations.ts +++ b/src/modules/module-operations.ts @@ -74,6 +74,7 @@ import { createModuleBunResolver, isRuntimeReason, type ModuleBunResolver, + runtimeMayPass, } from "./bun-runtime"; import { type DataMountHost, @@ -84,7 +85,7 @@ import { readPinnedBun } from "./install-authority"; import { createApplicationLifecycle } from "./lifecycle"; import { observeConnected } from "./listener-ownership"; import { localApplicationAdapters } from "./local-application-adapters"; -import { preparationRefusal } from "./preparation-refusal"; +import { PreparationRefused, preparationRefusal } from "./preparation-refusal"; import { readModuleApplication } from "./read-application"; import { type RuntimeSecretFinding, @@ -413,6 +414,35 @@ export type ModuleAnswer = Readonly<{ sync?: Readonly<{ result: "blocked"; reason: string }>; }>; +/** One built app's answer when `lazurio update` builds what changed (root + * decision 0212 point 3, `refreshBuilt`). */ +export type BuiltRefresh = Readonly<{ + /** The names the module routes take: the Organization's slug, the + * module's id and the app's package. */ + organization: string; + module: string; + app: string; + /** `built`: its checkout moved past its build and was built now, and a + * running build swapped onto it; `current`: its checkout's build is there + * already; `failed`: it could not be built now (`reason`), or the last + * build of this same checkout failed, which only an explicit build tries + * again. What runs stays as it was. */ + result: "built" | "current" | "failed"; + /** The commit the build is (or would be) of. */ + commit?: string; + /** `failed`: the build's own reason (`build-script-failed`, …). */ + reason?: string; +}>; + +/** A build `refreshBuilt` has begun, for a surface that shows progress. */ +export type BuiltProgress = Readonly<{ + organization: string; + module: string; + app: string; + step: "building"; + commit: string; +}>; + export type ModuleLogs = Readonly<{ kind: "module-logs"; organization: string; @@ -1803,6 +1833,26 @@ export function createModuleOperations(input: { : null; } + // Whether the Bun a target's app pins could come only from the network: + // neither the user's Bun nor in the runtime cache, by a local lookup + // (decision F62 point 9). A pin that cannot be read is its build's to name. + async function pinOnlyFromNetwork(target: Target): Promise { + const owner = dirname(join(target.moduleDirectory, target.app)); + try { + await resolveBun({ + packageManager: await readPinnedBun(owner), + owner, + fetch: false, + }); + return false; + } catch (error) { + return ( + error instanceof PreparationRefused && + error.reason === "runtime-unavailable" + ); + } + } + // The toolchain from the standard path, checked before any effect. async function toolchainMissing( operation: "start" | "prepare" | "build", @@ -2169,6 +2219,195 @@ export function createModuleOperations(input: { ? Object.freeze({ mode: "built", commit: served.commit }) : Object.freeze({ mode: "dev", commit: null }); }, + /** The resolver of the Bun each App pins (decision F62), the one every + * lifecycle and build of this host shares with its fetches under way: + * what the tools part of `lazurio update` makes those versions available + * with before any build. */ + resolveBun, + /** The Bun the apps of these module checkouts pin (decision F62), for the + * tools part of `lazurio update` (root decision 0212 point 3): the + * checkouts the content part just fast-forwarded, as the synchronization + * names them (the Organization's GitHub login, its root and the module's + * path). Read from the catalog and the packages as the checkouts are now, + * so as the commits the content brought declare them: every app the + * module declares, in order, each pin once. Only a package that was read + * and pins none adds nothing. A pin that cannot be read (a package.json + * missing, unreadable or pinning something other than an exact Bun) and + * a moved module the catalog does not declare are counted as + * `unreadable`, so the update reports them instead of looking clean. + * Nothing is resolved or fetched here. */ + async runtimePins( + moved: readonly Readonly<{ + organization: string; + root: string; + path: string; + }>[], + ): Promise> { + if (moved.length === 0) + return Object.freeze({ pins: Object.freeze([]), unreadable: 0 }); + const catalog = await readCatalog(folder, false); + const pins = new Set(); + let unreadable = 0; + for (const checkout of moved) { + const group = catalog.organizations.find( + (entry) => + entry.forgeLogin?.toLowerCase() === + checkout.organization.toLowerCase() && + `organizations/${entry.directory}` === checkout.root, + ); + const module = group?.modules.find( + (entry) => entry.path === checkout.path, + ); + if (module === undefined) { + unreadable += 1; + continue; + } + for (const app of module.apps) { + const owner = dirname( + join(folder, checkout.root, module.path, app.package), + ); + try { + const pin = await readPinnedBun(owner); + if (pin !== null) pins.add(pin); + } catch { + unreadable += 1; + } + } + } + return Object.freeze({ pins: Object.freeze([...pins]), unreadable }); + }, + /** Build what changed, now (root decision 0212 point 3, the builds of + * `lazurio update`): every built app with a build state (someone opened + * it once) whose checkout moved past its build is built in the + * foreground, one after the other, as the sweep's refresh builds it in + * the background, and a running build is swapped onto it. A checkout + * whose last build failed is not built again here, unless what failed + * may pass by itself (the App's pinned Bun could not be fetched, + * decision F62 point 7), and an app nobody opened is not built + * for the first time: its first Open builds it. The answer per app; + * nothing where this host keeps no builds, and nothing for the CLI + * beside a session's apps: their Launchpad owns their dependency trees + * until it ends (the lifecycle's retained lock), so it builds what moved + * itself, within seconds of the pull or at its next start. + * + * One run, one answer about the network (decision F62 point 9): told + * that this same `lazurio update` run's pre-warm found the registry not + * answering, an app whose pinned Bun is neither the user's nor in the + * runtime cache is not built now. It answers `failed` with + * `runtime-unavailable` at once, after a local lookup only, and the next + * run builds it. A pin there builds as always. Builds outside the run (a + * person's start, Open or build, the sweep) are never told and ask the + * registry themselves. */ + async refreshBuilt( + onProgress?: (progress: BuiltProgress) => void, + network?: Readonly<{ registryUnavailable: boolean }>, + ): Promise { + if (builder === undefined || host.builds === undefined) return []; + if (owner === "cli" && (await host.runnerKind()) === "session") return []; + const answers: BuiltRefresh[] = []; + const states = await listBuildStates(host.builds.cacheRoot).catch( + () => [], + ); + for (const { state } of states) { + if (closing) break; + const where = { + organization: state.organization, + module: state.module, + app: state.app, + }; + const answer = ( + result: BuiltRefresh["result"], + extra: Readonly<{ commit?: string; reason?: string }> = {}, + ) => answers.push(Object.freeze({ ...where, result, ...extra })); + try { + const prepared = await prepare( + "build", + `${state.organization}/${state.module}`, + { app: state.app }, + ); + // No longer in this Environment's catalog, or not runnable here: + // nothing to build. + if ("kind" in prepared) continue; + const plan = await builtPlan(prepared.target); + if (plan === null) continue; + const read = await buildContext(prepared.target); + if ("refused" in read) { + answer("failed", { reason: read.refused }); + continue; + } + const store = storeOf(prepared.target, plan); + const recorded = await store.state(); + if ( + recorded?.current?.key === read.key && + (await store.output(read.key)) !== null + ) { + answer("current", { commit: recorded.current.commit }); + continue; + } + // A failed checkout waits for an explicit build, unless what failed + // may pass by itself (decision F62 point 7), as for the builder. + if ( + recorded?.failure?.key === read.key && + !runtimeMayPass(recorded.failure.reason) + ) { + answer("failed", { + commit: recorded.failure.commit, + reason: recorded.failure.reason, + }); + continue; + } + if ((await toolchainMissing("build", prepared.target)) !== null) { + answer("failed", { + commit: read.head.commit, + reason: "toolchain-missing", + }); + continue; + } + if ( + network?.registryUnavailable === true && + (await pinOnlyFromNetwork(prepared.target)) + ) { + answer("failed", { + commit: read.head.commit, + reason: "runtime-unavailable", + }); + continue; + } + try { + onProgress?.( + Object.freeze({ + ...where, + step: "building" as const, + commit: read.head.commit, + }), + ); + } catch {} + const outcome = await builder.enqueue( + jobOf(prepared, plan), + "foreground", + ); + if (outcome.kind !== "built") { + answer("failed", { + commit: read.head.commit, + reason: + outcome.kind === "failed" + ? outcome.reason + : outcome.kind === "busy" + ? "build-busy" + : "closing", + }); + continue; + } + await swap(prepared, plan, outcome); + answer(outcome.already ? "current" : "built", { + commit: outcome.record.commit, + }); + } catch { + answer("failed", { reason: "operation-failed" }); + } + } + return Object.freeze(answers); + }, /** Keep the built apps people opened built after a pull (decision F58 * point 7): the Launchpad watches the Git directories their builds read * and sweeps at its start and every few minutes, building a moved diff --git a/src/update/cli.ts b/src/update/cli.ts index 7cfb256d..b82ec8ee 100644 --- a/src/update/cli.ts +++ b/src/update/cli.ts @@ -11,6 +11,9 @@ import { computerUseFinding, convergeComputerUse, } from "../computer/computer"; +import { processPersonalspaceHolder } from "../content/holder"; +import { contentLockDirectory } from "../content/host"; +import { syncContent } from "../content/sync"; import { type ExecutorReport, executorFinding, @@ -26,6 +29,12 @@ import { } from "../folder/refresh-needed"; import { LaunchpadStartRefused } from "../launchpad/start-check"; import { hostedOperatorFolder } from "../machine/operator"; +import { + createModuleOperations, + type ModuleHost, + processModuleHost, +} from "../modules/module-operations"; +import type { PersonalspaceHolderRead } from "../organizations/personalspace"; import { runTool } from "../tools/status"; import { type AttestationVerifier, @@ -42,7 +51,16 @@ import { exitUsage, storageFailure, type UpdateError, + updateError, } from "./errors"; +import { + contentNotRun, + everythingText, + type ProductItem, + type UpdateItem, + type UpdateParts, + updateEverything, +} from "./everything"; import { embeddedFixture, embeddedIdentity, @@ -68,6 +86,7 @@ import { performUpdate, readStatus, type UpdateEnvironment, + type UpdateResult, } from "./update"; /** `lazurio update …`, `lazurio install` and `lazurio --version`: the terminal @@ -110,7 +129,21 @@ install prompt [--locale cs|en] [--json] what the agent may do and what only on the user's explicit instruction, and how success is proven. Reads only; --json says "standard": true when nothing deviates. -update [--version ] [--folder ] [--json] +update [--folder ] [--json] + Brings everything up to date, in this order (root decision 0212 point 3): + the content (the clean primary checkouts of the Organizations this + Environment holds on their published branch, as lazurio organization sync + does), the tools (the Bun each app of a module that moved pins, fetched + into the runtime cache unless it is there already), the builds (every built + app someone opened whose checkout moved past its build is built and swapped + in), then Lazurio itself as below. Each part keeps its own lock and outcome, + and one that fails or cannot run never stops the next. The content, tools + and builds need the Folder (--folder, the supervised unit's, or in a Remote + Environment the declared operator's); without one only Lazurio is updated. + --json adds items, one per repository, pinned Bun, built app and the + product, to the product's own answer; the exit status is the product's. +update --version [--folder ] [--json] + Lazurio itself only, exactly that tag. Checks the latest release (or exactly the named tag), verifies its Sigstore attestation, downloads, runs the new executable's self-check and, on a supervised installation, its Launchpad start read-only on a private socket. @@ -162,6 +195,15 @@ export type CliContext = Readonly<{ * base (decision F44, addendum of 2026-10-11); absent: they never do. This * process's in production. */ executor?: (() => ExecutorHost) | undefined; + /** Where `lazurio update` synchronizes content and builds (root decision + * 0212 point 3): this process's module host and whose Personalspace this + * is. Absent: it does neither (tests of the product alone). */ + modules?: + | (() => Readonly<{ + host: ModuleHost; + personalspace?: PersonalspaceHolderRead | undefined; + }>) + | undefined; }>; export const processContext = (): CliContext => @@ -178,6 +220,10 @@ export const processContext = (): CliContext => platform: process.platform, run: runTool, }), + modules: () => ({ + host: processModuleHost(process.env, process.platform, process.execPath), + personalspace: processPersonalspaceHolder(process.env, process.platform), + }), }); const usage = (message: string): CommandOutput => @@ -405,9 +451,11 @@ function render(result: Result, json: boolean, human: string): CommandOutput { if (json) return Object.freeze({ code, stdout: JSON.stringify(result) }); if (!error) return Object.freeze({ code, stdout: human }); // The one context value a person needs: which state a person must look at. + // What the other parts of `lazurio update` did stays on stdout. const path = error.context.path; return Object.freeze({ code, + ...(human === "" ? {} : { stdout: human }), stderr: `Update failed: ${error.code}${path === undefined ? "" : ` (${path})`}`, }); } @@ -768,6 +816,68 @@ export async function runInstallCommand( } } +/** The product's answer as one item of `lazurio update`. */ +function productItem(result: UpdateResult): ProductItem { + if (result.kind === "updated") + return Object.freeze({ + part: "product", + result: "updated", + from: result.from, + to: result.to, + }); + if (result.kind === "up-to-date") + return Object.freeze({ + part: "product", + result: "current", + version: result.running, + }); + return Object.freeze({ + part: "product", + result: "failed", + code: result.code, + }); +} + +/** The content, tools and builds of `lazurio update` for this Folder (root + * decision 0212 point 3): the content core with this install base's content + * lock, and the module core as its CLI owner: it reads the Bun the moved + * modules pin and makes it available with its own resolver (decision F62), + * swaps an app the service manager runs and leaves a session's app to its + * Launchpad's sweep, which then finds that Bun in the cache. Nothing without + * a Folder or without the context's module host: then `lazurio update` is + * Lazurio's alone and answers exactly as before. */ +function folderParts( + context: CliContext, + base: string, + folder: string | undefined, +): Pick | null { + if (context.modules === undefined || folder === undefined) return null; + const { host, personalspace } = context.modules(); + const content = host.dataMounts; + const operations = createModuleOperations({ + folder, + owner: "cli", + host, + ...(personalspace === undefined ? {} : { personalspace }), + }); + return { + content: async (onProgress) => + content === undefined + ? contentNotRun("not-installed") + : syncContent( + folder, + { ...content, lockDirectory: contentLockDirectory(base) }, + { onProgress }, + ), + tools: { + pins: (moved) => operations.runtimePins(moved), + resolve: operations.resolveBun, + }, + builds: (onProgress, network) => + operations.refreshBuilt(onProgress, network), + }; +} + export async function runUpdateCommand( args: readonly string[], context: CliContext = processContext(), @@ -858,39 +968,73 @@ export async function runUpdateCommand( : "", ); } - const result = await performUpdate(environment, exact); + // `--version` names Lazurio only; plain `update` brings everything up + // to date, Lazurio last (root decision 0212 point 3). + const parts = + exact === undefined + ? folderParts(context, base, environment.folder) + : null; + let answered: UpdateResult | undefined; + const items: readonly UpdateItem[] = + parts === null + ? [] + : await updateEverything({ + ...parts, + product: async () => { + answered = await performUpdate(environment); + return productItem(answered); + }, + }); + const result: UpdateResult = + parts === null + ? await performUpdate(environment, exact) + : (answered ?? + Object.freeze({ + kind: "error" as const, + ...updateError("internal"), + })); + const before = everythingText(items); + const withItems = { ...result, items }; + const answer: Result = parts === null ? result : withItems; return render( - result, + answer, json, - result.kind === "updated" - ? [ - `Updated from ${result.from} to ${result.to}.${ - result.restartRequired - ? " A running Launchpad finishes the update when it restarts." - : "" - }`, - ...refresh(result.folderRefresh), - ...codexFinding(result.codexAppServer), - ...browserFinding(result.environmentBrowser), - ...computerUseFinding(result.computerUse), - ...executorFinding(result.executor), - ].join("\n") - : result.kind === "up-to-date" - ? [ - `Lazurio ${result.running} is up to date.`, - ...refresh(result.folderRefresh), - ...codexFinding(result.codexAppServer), - ...browserFinding(result.environmentBrowser), - ...computerUseFinding(result.computerUse), - ...executorFinding(result.executor), - ].join("\n") - : "", + [...before, productText(result)].filter((line) => line !== "").join("\n"), ); } catch (error) { return usage(error instanceof UsageError ? error.message : synopsis); } } +/** The product's own lines after `lazurio update`. */ +function productText(result: UpdateResult): string { + const refresh = (value: FolderRefresh | null) => + value === null ? [] : [folderRefreshText(value)]; + return result.kind === "updated" + ? [ + `Updated from ${result.from} to ${result.to}.${ + result.restartRequired + ? " A running Launchpad finishes the update when it restarts." + : "" + }`, + ...refresh(result.folderRefresh), + ...codexFinding(result.codexAppServer), + ...browserFinding(result.environmentBrowser), + ...computerUseFinding(result.computerUse), + ...executorFinding(result.executor), + ].join("\n") + : result.kind === "up-to-date" + ? [ + `Lazurio ${result.running} is up to date.`, + ...refresh(result.folderRefresh), + ...codexFinding(result.codexAppServer), + ...browserFinding(result.environmentBrowser), + ...computerUseFinding(result.computerUse), + ...executorFinding(result.executor), + ].join("\n") + : ""; +} + /** The one-line notice after other commands; reads `last-check.json` only. */ export async function noticeAfterCommand( context: CliContext = processContext(), diff --git a/src/update/everything.ts b/src/update/everything.ts new file mode 100644 index 00000000..375bf11c --- /dev/null +++ b/src/update/everything.ts @@ -0,0 +1,503 @@ +import { randomBytes } from "node:crypto"; +import type { SyncEntry, SyncProgress, SyncRun } from "../content/sync"; +import { + type ModuleBunResolver, + prewarmModuleRuntimes, + type RuntimePrewarm, + type RuntimeReason, +} from "../modules/bun-runtime"; +import type { BuiltProgress, BuiltRefresh } from "../modules/module-operations"; + +// One `lazurio update` (root decision 0212 point 3, decision F9's second +// amendment of 2026-10-24): the same command on a computer and on an Environment +// brings everything the user owns up to date, in parts that keep their own +// cores, locks and outcomes. In order: +// +// 1. content: the clean primary checkouts of the Organizations this +// Environment holds on their published branch (`syncContent`); +// 2. tools: the Bun each app of the module checkouts the content just +// fast-forwarded pins (decision F62), made available with the resolver +// a start uses (`prewarmModuleRuntimes`), before any build, so a build +// never waits for its module's Bun (DEV-6682); its one answer about the +// network holds for the builds of the same run; +// 3. builds: every built app someone opened whose checkout moved past its +// build, built and swapped in (`refreshBuilt`, decision F58); +// 4. product: Lazurio itself to the newest public release, last, because +// its activation restarts the Launchpad (docs/update.md). +// +// A part that fails, or cannot run, is one item of the answer and never +// stops the next: content never waits for the product, nor the product for +// content. The answer is a list of items, one per repository, pinned Bun, +// built app and product, and while it runs each item's step. The CLI prints +// it, and the Launchpad runs the same composition as a job +// (`createUpdateJobs`) for the Aktualizovat button of Nová karta. + +/** Why the content part did not run. */ +export const contentNotRunReasons = [ + "folder-unreadable", + "not-for-this-environment", + "content-busy", + "not-installed", +] as const; +export type ContentNotRun = (typeof contentNotRunReasons)[number]; + +/** A content part that does not run here, and why. */ +export const contentNotRun = (why: ContentNotRun) => + Object.freeze({ kind: "not-run" as const, reason: why }); + +/** Why a part answered as a whole: it threw (`partFailed`), or Lazurio is + * not installed here (`productNotInstalled`). Items, not the update's error + * contexts (`updateErrorReasons`). */ +export const partFailed = "operation-failed"; +export const productNotInstalled = "not-installed"; + +/** The product part's answer. */ +export type ProductItem = + | Readonly<{ part: "product"; result: "current"; version: string }> + /** The CLI switched the version itself. */ + | Readonly<{ + part: "product"; + result: "updated"; + from: string; + to: string; + }> + /** The Launchpad started `lazurio update` in its own unit, which + * outlives the restart it causes (docs/update.md "Activation"). */ + | Readonly<{ part: "product"; result: "started"; to: string }> + | Readonly<{ part: "product"; result: "failed"; code: string }> + | Readonly<{ + part: "product"; + result: "not-run"; + reason: typeof productNotInstalled; + }>; + +/** A module checkout the content part fast-forwarded in this run, as the + * synchronization names it: the Organization's GitHub login, its root + * relative to the Folder and the module's path relative to the root. */ +export type MovedModule = Readonly<{ + organization: string; + root: string; + path: string; +}>; + +/** The registry did not answer (decision F62): the first such refusal ends + * the pre-warm of the run. */ +export const runtimeUnavailable = "runtime-unavailable" satisfies RuntimeReason; +/** A download that did not verify, or a program that reports another + * version (decision F62): tampered or broken, so a warning. */ +export const runtimeIntegrityFailed = + "runtime-integrity-failed" satisfies RuntimeReason; + +/** The tools part's answer for one Bun an app pins (decision F62), by its + * pin (`bun@x.y.z`). `cached`: there already (the user's Bun is that + * version, or the runtime cache holds it), nothing fetched; `fetched`: now + * in the runtime cache; `refused`: it could not be had, with the reason a + * start would give (a `runtime-*` reason, or `operation-failed`), and + * `warning` for a download that did not verify; `not-fetched`: neither the + * user's Bun nor the cache has it, and it was not fetched because the + * registry did not answer for an earlier pin of this run. */ +export type ToolsItem = Readonly<{ part: "tools"; packageManager: string }> & + ( + | Readonly<{ result: "cached" | "fetched" }> + | Readonly<{ result: "refused"; reason: string; warning?: true }> + | Readonly<{ result: "not-fetched"; reason: typeof runtimeUnavailable }> + ); + +/** One item of the answer. */ +export type UpdateItem = + | (Readonly<{ part: "content" }> & SyncEntry) + | Readonly<{ part: "content"; result: "not-run"; reason: ContentNotRun }> + | ToolsItem + | (Readonly<{ part: "build" }> & BuiltRefresh) + | ProductItem + | Readonly<{ + part: "content" | "build" | "tools" | "product"; + result: "failed"; + reason: typeof partFailed; + }>; + +/** What the builds of one run know about the network: its pre-warm found + * the registry not answering (`runtime-unavailable`, and nothing else). + * Memory of that run only, never kept for the next. */ +export type UpdateNetwork = Readonly<{ registryUnavailable: boolean }>; + +/** An item while it is under way. A pin's `fetching` lasts while its Bun is + * made available: a fetch, or only the moment it takes to find it there. */ +export type UpdateStep = + | (Readonly<{ part: "content" }> & + Omit & + Readonly<{ step: "checking" | "fetching" | "fast-forwarding" }>) + | Readonly<{ part: "tools"; packageManager: string; step: "fetching" }> + | (Readonly<{ part: "build" }> & BuiltProgress) + | Readonly<{ part: "product"; step: "checking" }>; + +export type UpdateLine = UpdateItem | UpdateStep; + +export type UpdateParts = Readonly<{ + /** Synchronize content, reporting each repository as it goes; a reason + * when it cannot run here at all. */ + content: ( + onProgress: (progress: SyncProgress) => void, + ) => Promise>; + /** The Bun the apps of the module checkouts this run fast-forwarded pin + * (decision F62): their pins as the checkouts now declare them, with how + * many could not be read (`runtimePins` of the module core), and the + * resolver a start uses (the module core's `resolveBun`). Absent: no tools + * part. */ + tools?: Readonly<{ + pins: ( + moved: readonly MovedModule[], + ) => Promise>; + resolve: ModuleBunResolver; + }>; + /** Build what changed (`refreshBuilt`), told what the run's pre-warm + * found about the network. */ + builds: ( + onProgress: (progress: BuiltProgress) => void, + network: UpdateNetwork, + ) => Promise; + /** Lazurio itself, last. */ + product: () => Promise; +}>; + +/** How long the content part waits for another content operation (the + * Launchpad's hourly round, an installation) before it answers busy, and + * how often it asks again. */ +export const contentBusyWaitMs = 120_000; +export const contentBusyPollMs = 2_000; + +const keyOf = (line: UpdateLine): string => { + if (line.part === "content") + return "path" in line + ? `content\0${line.organization.toLowerCase()}\0${line.root}\0${line.path}` + : "content"; + if (line.part === "build") + return "app" in line + ? `build\0${line.organization}\0${line.module}\0${line.app}` + : "build"; + if (line.part === "tools") + return "packageManager" in line ? `tools\0${line.packageManager}` : "tools"; + return line.part; +}; + +/** One pin's pre-warm as an item. */ +function toolsItem(result: RuntimePrewarm): ToolsItem { + if (result.kind !== "refused") + return Object.freeze({ + part: "tools" as const, + packageManager: result.packageManager, + result: result.kind, + }); + return Object.freeze({ + part: "tools" as const, + packageManager: result.packageManager, + result: "refused" as const, + reason: result.reason, + ...(result.reason === runtimeIntegrityFailed + ? { warning: true as const } + : {}), + }); +} + +/** Runs the parts in order; `onChange` hears every line as it stands after + * each change. The answer: every item, in the order the parts gave them. */ +export async function updateEverything( + parts: UpdateParts, + onChange: (lines: readonly UpdateLine[]) => void = () => {}, + options: Readonly<{ + busyWaitMs?: number; + busyPollMs?: number; + sleep?: (ms: number) => Promise; + }> = {}, +): Promise { + const lines = new Map(); + const set = (line: UpdateLine) => { + lines.set(keyOf(line), Object.freeze(line)); + try { + onChange(Object.freeze([...lines.values()])); + } catch {} + }; + const failed = (part: UpdateItem["part"]) => + set({ part, result: "failed", reason: partFailed }); + const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms)); + + // 1. Content, waiting a while for another content operation. + const moved: MovedModule[] = []; + try { + const onProgress = (progress: SyncProgress) => { + if (progress.step === "done") { + if (progress.entry !== undefined) + set({ part: "content", ...progress.entry }); + return; + } + const { entry: _entry, ...where } = progress; + set({ part: "content", ...where, step: progress.step }); + }; + const deadline = Date.now() + (options.busyWaitMs ?? contentBusyWaitMs); + let run = await parts.content(onProgress); + while (run.kind === "busy" && Date.now() < deadline) { + await sleep(options.busyPollMs ?? contentBusyPollMs); + run = await parts.content(onProgress); + } + const why: ContentNotRun | null = + run.kind === "busy" + ? "content-busy" + : run.kind === "not-run" || run.kind === "not-allowed" + ? run.reason + : null; + if (why !== null) set({ part: "content", result: "not-run", reason: why }); + else if (run.kind === "content-sync") + for (const entry of run.entries) { + set({ part: "content", ...entry }); + if (entry.kind === "module" && entry.result === "fast-forwarded") + moved.push( + Object.freeze({ + organization: entry.organization, + root: entry.root, + path: entry.path, + }), + ); + } + } catch { + failed("content"); + } + + // 2. Tools (DEV-6682): the Bun each app of the checkouts the content just + // moved pins (decision F62), before any build, so that a build never waits + // for it. A pin can change only with a commit, and this run's commits are + // those the content brought; nothing moved, nothing to read. One pin at a + // time, each a line as soon as it is known. The first time the registry + // does not answer ends the fetches: each pin after that one is only looked + // up without the network, `cached` when the user's Bun or the cache has + // it, otherwise `not-fetched`. That one answer about the network holds for + // the builds of this run, and for nothing after it. A pin that cannot be + // had is an item, never a failed update. Pins that could not be read are + // one failed item of the part, after the others are pre-warmed. + let registryUnavailable = false; + const tools = parts.tools; + if (tools !== undefined && moved.length > 0) + try { + const local: ModuleBunResolver = (input) => + tools.resolve({ ...input, fetch: false }); + const { pins, unreadable } = await tools.pins(moved); + for (const packageManager of new Set(pins)) { + if (!registryUnavailable) + set({ part: "tools", packageManager, step: "fetching" }); + for (const result of await prewarmModuleRuntimes( + registryUnavailable ? local : tools.resolve, + [packageManager], + )) { + const unanswered = + result.kind === "refused" && result.reason === runtimeUnavailable; + set( + registryUnavailable && unanswered + ? { + part: "tools", + packageManager, + result: "not-fetched", + reason: runtimeUnavailable, + } + : toolsItem(result), + ); + if (unanswered) registryUnavailable = true; + } + } + if (unreadable > 0) failed("tools"); + } catch { + failed("tools"); + } + + // 3. Builds of what changed, the content's new commits among them, told + // what the pre-warm found about the network. + try { + const built = await parts.builds( + (progress) => set({ part: "build", ...progress }), + Object.freeze({ registryUnavailable }), + ); + for (const answer of built) set({ part: "build", ...answer }); + } catch { + failed("build"); + } + + // 4. Lazurio itself, last: its activation restarts the Launchpad. + try { + set({ part: "product", step: "checking" }); + set(await parts.product()); + } catch { + failed("product"); + } + return Object.freeze( + [...lines.values()].filter((line): line is UpdateItem => "result" in line), + ); +} + +// ---- The Launchpad's job ----------------------------------------------------- + +// `POST /api/update/everything` starts one run of the composition in the +// Launchpad's process, `GET /api/update/everything` reads the newest. One at a +// time: a start while one runs answers that run, so a second click joins it. +// Memory of this process only: a product activation restarts the Launchpad +// and the job goes with it, while what the run did stays in its owners' +// records (the content's record, the builds' states, the product's selector). + +export type UpdateJob = Readonly<{ + id: string; + state: "running" | "succeeded"; + /** Each line as it stands: an item's step while under way, then its + * answer. */ + lines: readonly UpdateLine[]; +}>; + +export function createUpdateJobs( + input: Readonly<{ + run: (onChange: (lines: readonly UpdateLine[]) => void) => Promise; + }>, +) { + let latest: { + id: string; + state: UpdateJob["state"]; + lines: readonly UpdateLine[]; + } | null = null; + let running: Promise | null = null; + const snapshot = (): UpdateJob | null => + latest === null + ? null + : Object.freeze({ + id: latest.id, + state: latest.state, + lines: latest.lines, + }); + return Object.freeze({ + /** Starts a run, or answers the one under way. */ + start(): Readonly<{ job: string; joined: boolean }> { + if (latest !== null && latest.state === "running") + return Object.freeze({ job: latest.id, joined: true }); + const job: { + id: string; + state: UpdateJob["state"]; + lines: readonly UpdateLine[]; + } = { + id: randomBytes(16).toString("hex"), + state: "running", + lines: Object.freeze([]), + }; + latest = job; + running = input + .run((lines) => { + job.lines = lines; + }) + .catch(() => undefined) + .then(() => { + job.state = "succeeded"; + running = null; + }); + return Object.freeze({ job: job.id, joined: false }); + }, + /** The newest run of this Launchpad, running or ended; null before the + * first. */ + latest: snapshot, + /** Resolves when no run is under way (shutdown, tests). */ + async settled(): Promise { + while (running !== null) await running; + }, + }); +} + +// ---- In words, for the CLI ------------------------------------------------- + +const short = (commit: string | undefined) => commit?.slice(0, 12) ?? ""; + +const where = (entry: SyncEntry) => + entry.path === "." ? entry.root : `${entry.root}/${entry.path}`; + +/** A pin in words: `bun@1.4.3` is Bun 1.4.3. */ +const bunOf = (packageManager: string) => + packageManager.startsWith("bun@") + ? `Bun ${packageManager.slice("bun@".length)}` + : packageManager; + +/** What `lazurio update` prints of content, tools and builds, before the + * product's own lines: a repository that moved or is left as it is, and a + * summary; a pinned Bun fetched or not; a built app that was built or could + * not be. Nothing where the part had nothing to do here (an Environment that + * keeps no Organization, a Bun that was there already). */ +export function everythingText(items: readonly UpdateItem[]): string[] { + const lines: string[] = []; + const entries: SyncEntry[] = []; + for (const item of items) { + if (item.part !== "content") continue; + if ("path" in item) entries.push(item); + else if (item.result === "not-run") { + if (item.reason === "content-busy") + lines.push( + "Content was not synchronized: another content operation kept the Folder busy (content-busy). Run lazurio update again.", + ); + else if (item.reason === "folder-unreadable") + lines.push( + "Content was not synchronized: the Folder's state could not be read (folder-unreadable).", + ); + } else + lines.push("Content was not synchronized: it failed (operation-failed)."); + } + for (const entry of entries) { + if (entry.result === "fast-forwarded") + lines.push( + `${where(entry)} (${entry.branch}): fast-forwarded ${short(entry.from)}..${short(entry.to)}`, + ); + else if (entry.result !== "current" && entry.result !== "absent") + lines.push( + `${where(entry)} (${entry.branch}): ${entry.result}${entry.reason === undefined ? "" : ` ${entry.reason}`}, left as it is`, + ); + } + if (entries.length > 0) { + const moved = entries.filter( + (entry) => entry.result === "fast-forwarded", + ).length; + const left = entries.filter( + (entry) => + !["current", "fast-forwarded", "absent"].includes(entry.result), + ).length; + lines.push( + `Content: ${entries.length} repositories checked, ${moved} fast-forwarded, ${left} left as they are.`, + ); + } + for (const item of items) { + if (item.part !== "tools") continue; + if (!("packageManager" in item)) { + lines.push( + "The Bun the moved modules pin could not all be read (operation-failed); a start or build of their apps names why.", + ); + continue; + } + const bun = bunOf(item.packageManager); + if (item.result === "fetched") + lines.push(`Fetched ${bun} for the apps that pin it.`); + else if (item.result === "not-fetched") + lines.push( + `${bun} was not tried: the registry did not answer this run (${item.reason}).`, + ); + else if (item.result === "refused") + lines.push( + item.warning + ? `Warning: ${bun} did not verify and was discarded (${item.reason}).` + : item.reason === runtimeUnavailable + ? `${bun} was not fetched: the registry did not answer (${item.reason}).` + : `${bun} was not fetched (${item.reason}).`, + ); + } + for (const item of items) { + if (item.part !== "build") continue; + if (!("app" in item)) { + lines.push("Builds failed (operation-failed)."); + continue; + } + const name = `${item.organization}/${item.module} (${item.app})`; + if (item.result === "built") + lines.push(`Built ${name} at ${short(item.commit)}.`); + else if (item.result === "failed") + lines.push( + `${name} was not built${item.reason === undefined ? "" : ` (${item.reason})`}; what runs stays as it was.`, + ); + } + return lines; +} diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index 4745cb9c..c4bdbf59 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -24,6 +24,7 @@ import { bunTargets, createModuleBunResolver, type ModuleBun, + moduleBunLocks, moduleBunRoot, prewarmModuleRuntimes, runtimeFreeBytesMin, @@ -630,6 +631,64 @@ posixTest( }, ); +posixTest( + "a lookup without the network (fetch: false, decision F62 point 9): the user's Bun or the cache, otherwise runtime-unavailable at once, never the installer, no wait for a fetch under way and nothing written", + async () => { + const { home, owner, file } = await place("lookup"); + const fake = fakeInstaller(); + const resolve = createModuleBunResolver({ + home, + userBun: process.execPath, + path: "/usr/bin:/bin", + install: fake.install, + system: async () => linuxArm, + freeBytes: plenty, + fetches: new Map(), + }); + expect( + await resolve({ + packageManager: `bun@${Bun.version}`, + owner, + fetch: false, + }), + ).toEqual({ executable: process.execPath, binDir: null, source: "user" }); + expect( + await resolve({ packageManager: null, owner, fetch: false }), + ).toMatchObject({ source: "user" }); + // Not there: refused at once, named by the package that pins it. + expect( + await refusalOf( + resolve({ packageManager: "bun@1.4.99", owner, fetch: false }), + ), + ).toEqual({ reason: "runtime-unavailable", path: file }); + expect(fake.calls).toHaveLength(0); + expect(existsSync(moduleBunRoot(home))).toBe(false); + // Another process fetching that version holds its exclusion: the lookup + // does not wait for it. + await mkdir(moduleBunLocks(home), { recursive: true }); + const held = await acquireFileLock(join(moduleBunLocks(home), "1.4.98"), { + timeoutMs: 1_000, + }); + try { + expect( + await refusalOf( + resolve({ packageManager: "bun@1.4.98", owner, fetch: false }), + ), + ).toEqual({ reason: "runtime-unavailable", path: file }); + } finally { + await held.release(); + } + // Once in the cache, the lookup finds it there, still without a fetch. + const executable = cachedExecutable(home, "1.4.99", "linux-aarch64"); + await resolve({ packageManager: "bun@1.4.99", owner }); + expect(fake.calls).toHaveLength(1); + expect( + await resolve({ packageManager: "bun@1.4.99", owner, fetch: false }), + ).toEqual({ executable, binDir: dirname(executable), source: "cache" }); + expect(fake.calls).toHaveLength(1); + }, +); + posixTest( "a pre-warm (root decision 0212) makes each distinct pinned Bun available with the resolver a start uses: one result per pin, cached, fetched or refused with the start's reason, never a throw", async () => { diff --git a/tests/launchpad-update.test.ts b/tests/launchpad-update.test.ts index 938172c2..c4457142 100644 --- a/tests/launchpad-update.test.ts +++ b/tests/launchpad-update.test.ts @@ -4,6 +4,8 @@ import { initializeFolder } from "../src/folder/initialize-folder"; import { executionOs } from "../src/folder/platform"; import { startLaunchpad } from "../src/launchpad/server"; import { createUpdatePill } from "../src/launchpad/update-pill"; +import type { ModuleHost } from "../src/modules/module-operations"; +import { createSessionRunner } from "../src/modules/session-runner"; import type { UpdateError } from "../src/update/errors"; import type { Activator } from "../src/update/launchpad-activation"; import { @@ -156,3 +158,196 @@ test.skipIf(process.platform === "win32")( } }, ); + +// Aktualizovat (root decision 0212 points 3 and 7): one `lazurio update` as a +// job of the installed Launchpad, behind the same admission, Lazurio itself +// last through the pill's own action; a second click joins the run. +test.skipIf(process.platform === "win32")( + "POST and GET /api/update/everything: one run at a time that a second click joins, Lazurio last through the pill", + async () => { + world = await createWorld(); + await world.release("1.1.0"); + const folder = join(world.root, "Lazurio"); + await initializeFolder(folder, { + os: executionOs(process.platform), + access: "local", + purpose: "human", + locale: "en", + detail: "concise", + coordination: "direct", + }); + const starts: string[] = []; + let release: () => void = () => {}; + const released = new Promise((resolve) => { + release = resolve; + }); + const activator: Activator = { + async start(version) { + starts.push(version); + // The run stays under way until the test lets it go on. + await released; + }, + async observe() { + return { inFlight: false, failure: null }; + }, + }; + const pill = createUpdatePill({ + environment: world.environment("1.0.0"), + activator, + poller: { startupDelayMs: 3_600_000, intervalMs: 3_600_000, jitterMs: 0 }, + }); + // No builds and no tools of the account: nothing but this Folder. + const moduleHost: ModuleHost = { + platform: process.platform, + home: world.root, + path: "/usr/bin:/bin", + runtimeDirectory: undefined, + platformExecutable: process.execPath, + runnerKind: async () => "session", + createRunner: () => createSessionRunner(process.execPath), + readJournal: async () => ({ kind: "unavailable" }), + }; + const app = await startLaunchpad( + folder, + undefined, + undefined, + { base: world.base, version: "1.0.0", pill }, + {}, + { + path: world.root, + home: world.root, + platform: process.platform, + run: async () => "timeout", + }, + {}, + undefined, + moduleHost, + ); + const url = new URL(app.url); + const auth = { Authorization: `Bearer ${url.hash.slice(1)}` }; + const route = new URL("/api/update/everything", url); + const post = (override: Record = {}) => + fetch(route, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: url.origin, + ...auth, + ...override, + }, + body: "{}", + }); + const latest = async () => + (await (await fetch(route, { headers: auth })).json()) as { + kind: string; + job: { id: string; state: string; lines: unknown[] } | null; + }; + try { + expect(await latest()).toEqual({ kind: "update-everything", job: null }); + // The admission of every route: the token, and the same origin for a + // write. + expect((await fetch(route)).status).toBe(403); + expect((await post({ Origin: "https://x.example" })).status).toBe(403); + expect( + ( + await fetch(route, { + method: "POST", + headers: { + ...auth, + "Content-Type": "application/json", + Origin: url.origin, + }, + body: '{"extra":1}', + }) + ).status, + ).toBe(400); + const started = await post(); + expect(started.status).toBe(202); + const first = (await started.json()) as { job: string; joined: boolean }; + expect(first.joined).toBe(false); + // Lazurio's check verified 1.1.0 and the pill's action started it. + for (let attempt = 0; attempt < 500 && starts.length === 0; attempt++) + await Bun.sleep(10); + expect(starts).toEqual(["1.1.0"]); + // A second click joins the run under way. + const joined = await post(); + expect([joined.status, await joined.json()]).toEqual([ + 202, + { kind: "update-everything", job: first.job, joined: true }, + ]); + expect((await latest()).job).toMatchObject({ + id: first.job, + state: "running", + lines: [{ part: "product", step: "checking" }], + }); + release(); + let job = (await latest()).job; + for ( + let attempt = 0; + attempt < 500 && job?.state !== "succeeded"; + attempt++ + ) { + await Bun.sleep(10); + job = (await latest()).job; + } + // This Folder holds no Organization: content had nothing to do. + expect(job).toEqual({ + id: first.job, + state: "succeeded", + lines: [{ part: "product", result: "started", to: "1.1.0" }], + }); + } finally { + release(); + await app.close(); + } + // A Launchpad that is not an installed one runs no update. + const plain = await startLaunchpad(folder); + const plainUrl = new URL(plain.url); + try { + const response = await fetch( + new URL("/api/update/everything", plainUrl), + { + headers: { Authorization: `Bearer ${plainUrl.hash.slice(1)}` }, + }, + ); + expect([response.status, await response.json()]).toEqual([ + 503, + { error: "update-unavailable" }, + ]); + } finally { + await plain.close(); + } + }, +); + +test.skipIf(process.platform === "win32")( + "the pill's part of one update: current when nothing newer is verified, a failure by its code, and nothing started then", + async () => { + world = await createWorld(); + const starts: string[] = []; + const activator: Activator = { + async start(version) { + starts.push(version); + }, + async observe() { + return { inFlight: false, failure: null }; + }, + }; + const pill = createUpdatePill({ + environment: world.environment("1.0.0"), + activator, + poller: { startupDelayMs: 3_600_000, intervalMs: 3_600_000, jitterMs: 0 }, + }); + // No release yet: the check fails and says why. + const failed = await pill.updateNow(); + expect(failed).toMatchObject({ part: "product", result: "failed" }); + expect(typeof (failed as { code?: unknown }).code).toBe("string"); + await world.release("1.0.0"); + expect(await pill.updateNow()).toEqual({ + part: "product", + result: "current", + version: "1.0.0", + }); + expect(starts).toEqual([]); + }, +); diff --git a/tests/module-built.test.ts b/tests/module-built.test.ts index fc5b6b28..daedf91d 100644 --- a/tests/module-built.test.ts +++ b/tests/module-built.test.ts @@ -1,9 +1,16 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { createConnection } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { processContentGit } from "../src/content/git"; import type { ContentGitHub } from "../src/content/github"; import { syncContent } from "../src/content/sync"; @@ -13,6 +20,11 @@ import { messages } from "../src/launchpad/messages"; import { moduleStatusView } from "../src/launchpad/module-view"; import type { ApplicationRunner } from "../src/modules/application-runner"; import { gitCheckoutReader } from "../src/modules/built/checkout"; +import { + type BunInstaller, + createModuleBunResolver, + type ModuleBunResolver, +} from "../src/modules/bun-runtime"; import type { DataMountHost } from "../src/modules/data-mounts"; import { observeConnected } from "../src/modules/listener-ownership"; import { @@ -25,9 +37,17 @@ import { import { createSessionRunner } from "../src/modules/session-runner"; import { type Catalog, readFolderCatalog } from "../src/organizations/catalog"; import { runTool } from "../src/tools/status"; +import { type CliContext, runUpdateCommand } from "../src/update/cli"; +import { updateEverything } from "../src/update/everything"; import { writeOrganization } from "./fixtures/catalog-folder"; import { compilePlatform, runnableModule } from "./fixtures/module-host"; import { mkdirOwnedFixture as mkdir } from "./fixtures/owned-files"; +import { + closeSharedSigstore, + commitOf, + createWorld as createUpdateWorld, + target, +} from "./fixtures/update-world"; // A static app through the module lifecycle (decision F58, root decision // 0195 point 19), with real processes: a synthetic module whose build script @@ -47,6 +67,7 @@ beforeAll(async () => { }); afterAll(async () => { if (root) await rm(root, { recursive: true, force: true }); + await closeSharedSigstore(); }); // The build script: a page naming the checkout's `page.txt`, a content-named @@ -152,6 +173,9 @@ async function staticModule( /** This host's gh, git and content locks (data mounts, and the record * of the synchronization). */ dataMounts?: DataMountHost; + /** The resolver of the Bun each app pins (decision F62): the module + * operations' test seam. */ + resolveBun?: ModuleBunResolver; }> = {}, ) { const parent = join(root, name); @@ -216,6 +240,9 @@ async function staticModule( ...(options.dataMounts === undefined ? {} : { dataMounts: options.dataMounts }), + ...(options.resolveBun === undefined + ? {} + : { resolveBun: options.resolveBun }), builds: { cacheRoot: join(parent, "cache"), lockFile: join(parent, "state/lazurio/builds.lock"), @@ -791,6 +818,512 @@ posixTest( 60_000, ); +/** `staticModule` whose Organization root and site are primary checkouts of + * their published repositories (bare repositories reached as github.com + * through git's own `insteadOf`), with the content host that reaches them; + * `publish` puts a new page on site's published main from elsewhere, and + * with `pin` the Bun its app pins (`packageManager`). */ +async function publishedSite( + name: string, + home: string, + resolveBun?: ModuleBunResolver, +) { + const remotes = join(root, `${name}-remotes`); + await mkdir(join(remotes, "alpha"), { recursive: true }); + const gitEnv = { + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: `url.${remotes}/.insteadOf`, + GIT_CONFIG_VALUE_0: "git@github.com:", + GIT_CONFIG_KEY_1: "protocol.file.allow", + GIT_CONFIG_VALUE_1: "always", + }; + const content: DataMountHost = { + github: { + remote: async (repository: string) => ({ + url: `git@github.com:${repository}.git`, + }), + } as unknown as ContentGitHub, + git: processContentGit({ + path: Bun.env.PATH, + home, + platform: process.platform, + run: runTool, + env: gitEnv, + }), + lockDirectory: join(root, `${name}-base`, "content"), + }; + const fixture = await staticModule(name, home, { + published: true, + dataMounts: content, + ...(resolveBun === undefined ? {} : { resolveBun }), + }); + const git = (cwd: string, ...args: string[]) => + execFileSync( + "git", + [ + "-C", + cwd, + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + ...args, + ], + { encoding: "utf8", env: { ...process.env, ...gitEnv } }, + ).trim(); + const organization = join(fixture.folder, "organizations", "alpha"); + await writeFile(join(organization, ".gitignore"), "workspace/\n"); + git(organization, "init", "-q", "-b", "main"); + git(organization, "add", "-A"); + git(organization, "commit", "-qm", "Root"); + for (const [directory, repository] of [ + [organization, "alpha"], + [fixture.site, "site"], + ] as const) { + git(remotes, "clone", "-q", "--bare", directory, `alpha/${repository}.git`); + git( + directory, + "remote", + "add", + "origin", + `git@github.com:alpha/${repository}.git`, + ); + git(directory, "fetch", "-q", "origin"); + } + const scratch = join(root, name, "scratch"); + git(root, "clone", "-q", "git@github.com:alpha/site.git", scratch); + return { + ...fixture, + content, + publish: async (page: string, pin?: string) => { + await writeFile(join(scratch, "app/page.txt"), `${page}\n`); + if (pin !== undefined) { + const file = join(scratch, "app/package.json"); + const pkg = JSON.parse(await readFile(file, "utf8")); + pkg.packageManager = pin; + await writeFile(file, JSON.stringify(pkg)); + } + git(scratch, "commit", "-qam", page); + git(scratch, "push", "-q", "origin", "main"); + return git(scratch, "rev-parse", "HEAD"); + }, + }; +} + +posixTest( + "lazurio update with no newer Lazurio and a module behind: its checkout fast-forwards, --json lists every item with Lazurio last, and the new commit is built and swapped in (root decision 0212 point 3)", + async () => { + const home = join(root, "home-update"); + await mkdir(home); + const fixture = await publishedSite("update", home); + const { operations, folder } = fixture; + // No newer Lazurio: the latest release is the running version. + const product = await createUpdateWorld(); + await product.release("1.0.0"); + const context: CliContext = { + identity: { version: "1.0.0", commit: commitOf("1.0.0"), target }, + platform: process.platform, + env: { HOME: product.root, XDG_DATA_HOME: product.root }, + executable: join(product.root, "downloaded-lazurio"), + environment: product.environment("1.0.0"), + modules: () => ({ host: fixture.host }), + }; + const update = (...args: string[]) => + runUpdateCommand( + ["--base", product.base, "--folder", folder, ...args], + context, + ); + try { + // site runs as its build, a session's app of this Launchpad. + const started = answer(await operations.start("alpha/site")); + expect(started.build?.mode).toBe("built"); + await until(fixture.page, (page) => page.text === "

one

"); + const keeper = operations.keepBuilt({ quietMs: 100, sweepMs: 60_000 }); + await keeper?.sweep(); + await keeper?.settled(); + const published = await fixture.publish("two"); + const ran = await update("--json"); + expect(ran.code).toBe(0); + const result = JSON.parse(ran.stdout ?? ""); + expect(result).toMatchObject({ kind: "up-to-date", running: "1.0.0" }); + // One item per repository, pinned Bun and the product, in the order + // the parts ran, Lazurio last. site pins the user's own Bun, which is + // there already. A session's apps are built by their Launchpad, which + // owns their dependency trees: no build item from the CLI. + expect( + result.items.map((item: Record) => + [item.part, item.path ?? item.packageManager ?? "", item.result].join( + " ", + ), + ), + ).toEqual([ + "content . current", + "content workspace/site fast-forwarded", + `tools bun@${Bun.version} cached`, + "product current", + ]); + expect(result.items[1]).toMatchObject({ + organization: "alpha", + branch: "main", + to: published, + }); + expect(fixture.head()).toBe(published); + // The Launchpad's watch of site's checkout builds the new commit and + // swaps it in. + const swapped = answer( + await until( + () => operations.status("alpha/site"), + (status) => + status.kind === "module" && + status.build?.served?.commit === published, + ), + ); + expect(swapped.build?.served?.commit).toBe(published); + expect((await fixture.page()).text).toBe("

two

"); + // In words: the content's summary, then Lazurio's own line. + expect((await update()).stdout).toBe( + [ + "Content: 2 repositories checked, 0 fast-forwarded, 0 left as they are.", + "Lazurio 1.0.0 is up to date.", + ].join("\n"), + ); + // A product that fails still answers what the content did. + await product.close(); + const offline = await update("--json"); + expect(offline.code).toBe(1); + const failed = JSON.parse(offline.stdout ?? ""); + expect(failed.kind).toBe("error"); + expect( + failed.items.map((item: Record) => item.result), + ).toEqual(["current", "current", "failed"]); + const words = await update(); + expect(words.stdout).toContain("Content: 2 repositories checked"); + expect(words.stderr).toStartWith("Update failed: "); + } finally { + await operations.stop("alpha/site"); + await operations.close(); + await product.close().catch(() => undefined); + } + }, + 120_000, +); + +posixTest( + "the Launchpad's own run builds what changed and swaps it at once; a commit whose build failed is not built again, and an app nobody opened is not built for the first time", + async () => { + const home = join(root, "home-refresh"); + await mkdir(home); + const fixture = await publishedSite("refresh", home); + const { operations, folder, content } = fixture; + const run = () => + updateEverything({ + content: (onProgress) => syncContent(folder, content, { onProgress }), + builds: (onProgress) => operations.refreshBuilt(onProgress), + product: async () => ({ + part: "product", + result: "current", + version: "1.0.0", + }), + }); + try { + answer(await operations.start("alpha/site")); + await until(fixture.page, (page) => page.text === "

one

"); + const two = await fixture.publish("two"); + const items = await run(); + expect(items.filter((item) => item.part === "build")).toEqual([ + { + part: "build", + organization: "alpha", + module: "site", + app: "app/package.json", + result: "built", + commit: two, + }, + ]); + // Swapped by this run itself, no sweep. + expect((await fixture.page()).text).toBe("

two

"); + // A commit whose build fails: the previous build keeps serving, and + // the next run does not build it again. + const broken = await fixture.publish("fail"); + const failed = (await run()).find((item) => item.part === "build"); + expect(failed).toMatchObject({ + result: "failed", + reason: "build-script-failed", + commit: broken, + }); + expect((await fixture.page()).text).toBe("

two

"); + const failure = async () => + answer(await operations.status("alpha/site")).build?.failure; + const recorded = await failure(); + expect(recorded?.commit).toBe(broken); + expect((await run()).find((item) => item.part === "build")).toMatchObject( + { result: "failed", reason: "build-script-failed" }, + ); + // The same failure, not a new attempt. + expect(await failure()).toEqual(recorded); + // next was never opened: it has no build state, and gets none here. + expect( + (await run()).some( + (item) => + item.part === "build" && "module" in item && item.module === "next", + ), + ).toBe(false); + } finally { + await operations.stop("alpha/site"); + await operations.close(); + } + }, + 120_000, +); + +posixTest( + "runtimePins reads the Bun each app of the moved modules pins, each once: a package that pins none adds nothing, and a pin that cannot be read, or a module whose declaration is not there, is counted, never silently left out", + async () => { + const home = join(root, "home-pins"); + await mkdir(home); + const fixture = await staticModule("pins", home); + const { operations, folder } = fixture; + const workspace = join(folder, "organizations", "alpha", "workspace"); + const repin = async (module: string, pin: string | undefined) => { + const file = join(workspace, module, "app", "package.json"); + const pkg = JSON.parse(await readFile(file, "utf8")); + if (pin === undefined) delete pkg.packageManager; + else pkg.packageManager = pin; + await writeFile(file, JSON.stringify(pkg)); + }; + const at = (path: string) => ({ + organization: "Alpha", + root: "organizations/alpha", + path, + }); + try { + // site pins the user's own Bun; next pins none. + await repin("next", undefined); + expect( + await operations.runtimePins([ + at("workspace/site"), + at("workspace/next"), + ]), + ).toEqual({ pins: [`bun@${Bun.version}`], unreadable: 0 }); + // plain pins something that is not an exact Bun, and a moved path the + // catalog does not declare cannot give its pins either. + await repin("plain", "npm@10.0.0"); + expect( + await operations.runtimePins([ + at("workspace/site"), + at("workspace/plain"), + at("workspace/next"), + at("workspace/gone"), + ]), + ).toEqual({ pins: [`bun@${Bun.version}`], unreadable: 2 }); + // An app whose package.json is gone is counted too. + await rm(join(workspace, "next", "app", "package.json")); + expect(await operations.runtimePins([at("workspace/next")])).toEqual({ + pins: [], + unreadable: 1, + }); + } finally { + await operations.close(); + } + }, +); + +/** Bun's npm distribution as a fake for the resolver (decision F62): for + * each version a program named bun that logs every call, reports the version + * and runs the real Bun otherwise; a version in `offline` is refused as a + * registry that does not answer. No network. */ +function bunDistribution(directory: string) { + const offline = new Set(); + const asked: string[] = []; + const install: BunInstaller = async (input) => { + asked.push(input.version); + if (offline.has(input.version)) + return { kind: "refused", reason: "runtime-unavailable" }; + const program = join( + input.directory, + "node_modules", + input.packageName, + "bin", + "bun", + ); + await mkdir(dirname(program), { recursive: true }); + await writeFile( + program, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >> '${join(directory, `bun-${input.version}.log`)}'`, + `if [ "$1" = "--version" ]; then echo '${input.version}'; exit 0; fi`, + `exec '${process.execPath}' "$@"`, + "", + ].join("\n"), + ); + await chmod(program, 0o755); + return { kind: "installed" }; + }; + return { + install, + offline, + asked, + /** What that version's program ran, its version probes left out. */ + calls: async (version: string) => + ( + await readFile(join(directory, `bun-${version}.log`), "utf8").catch( + () => "", + ) + ) + .split("\n") + .filter((line) => line !== "" && line !== "--version"), + }; +} + +posixTest( + "the tools part fetches the Bun a fast-forwarded module's new commit pins before its build, which runs with it; offline, the run's build refuses at once without asking the registry again, a person's build still asks it, a later run builds it, and a cached pin builds under the offline answer (decision F62, root decision 0212 point 3)", + async () => { + const home = join(root, "home-tools"); + await mkdir(home); + const distribution = bunDistribution(join(root, "tools-distribution")); + await mkdir(join(root, "tools-distribution")); + const resolveBun = createModuleBunResolver({ + home, + userBun: process.execPath, + path: "/usr/bin:/bin", + install: distribution.install, + system: async () => ({ + platform: "linux", + arch: "arm64", + libc: "glibc", + avx2: null, + }), + freeBytes: async () => 10 * 1024 ** 3, + fetches: new Map(), + }); + const fixture = await publishedSite("tools", home, resolveBun); + const { operations, folder, content } = fixture; + // What the installer had been asked for when each build began. + const atBuild: string[][] = []; + const run = () => + updateEverything( + { + content: (onProgress) => syncContent(folder, content, { onProgress }), + tools: { + pins: (moved) => operations.runtimePins(moved), + resolve: operations.resolveBun, + }, + builds: (onProgress, network) => + operations.refreshBuilt((progress) => { + atBuild.push([...distribution.asked]); + onProgress(progress); + }, network), + product: async () => ({ + part: "product", + result: "current", + version: "1.0.0", + }), + }, + () => {}, + ); + const parts = (items: Awaited>) => + items.map((item) => + [ + item.part, + "path" in item + ? item.path + : "packageManager" in item + ? item.packageManager + : "module" in item + ? item.module + : "", + item.result, + "reason" in item ? (item.reason ?? "") : "", + ] + .filter((word) => word !== "") + .join(" "), + ); + try { + answer(await operations.start("alpha/site")); + await until(fixture.page, (page) => page.text === "

one

"); + // A new commit that pins a Bun the cache does not hold. + const two = await fixture.publish("two", "bun@1.4.99"); + const first = await run(); + expect(parts(first)).toEqual([ + "content . current", + "content workspace/site fast-forwarded", + "tools bun@1.4.99 fetched", + "build site built", + "product current", + ]); + // Fetched once, by the tools part, before the build began. + expect(distribution.asked).toEqual(["1.4.99"]); + expect(atBuild).toEqual([["1.4.99"]]); + // The build ran with exactly that Bun, and its page is served. + expect(await distribution.calls("1.4.99")).toContain( + "--no-env-file run build", + ); + expect(first.find((item) => item.part === "build")).toMatchObject({ + result: "built", + commit: two, + }); + expect((await fixture.page()).text).toBe("

two

"); + // Offline: a commit that pins another Bun. The pre-warm stops at the + // registry that does not answer, and the run's build, told so, refuses + // at once: no second attempt at the registry in this run. What runs + // stays as it was. + distribution.offline.add("1.4.98"); + await fixture.publish("three", "bun@1.4.98"); + atBuild.length = 0; + const offline = await run(); + expect(parts(offline)).toEqual([ + "content . current", + "content workspace/site fast-forwarded", + "tools bun@1.4.98 refused runtime-unavailable", + "build site failed runtime-unavailable", + "product current", + ]); + expect(distribution.asked).toEqual(["1.4.99", "1.4.98"]); + expect(atBuild).toEqual([]); + expect((await fixture.page()).text).toBe("

two

"); + // A person's build outside the update run still asks the registry + // itself, and its failure is recorded. + expect(await operations.build("alpha/site")).toMatchObject({ + kind: "blocked", + reason: "runtime-unavailable", + }); + expect(distribution.asked).toEqual(["1.4.99", "1.4.98", "1.4.98"]); + // Online again: nothing new to fast-forward, so no pins; the build + // whose runtime could not be had is built again (decision F62 point + // 7) and fetches its Bun itself. + distribution.offline.delete("1.4.98"); + const later = await run(); + expect(parts(later)).toEqual([ + "content . current", + "content workspace/site current", + "build site built", + "product current", + ]); + expect(await distribution.calls("1.4.98")).toContain( + "--no-env-file run build", + ); + expect((await fixture.page()).text).toBe("

three

"); + // Under the offline answer a pin the cache holds builds as always: a + // local lookup, no fetch. + const asked = [...distribution.asked]; + await fixture.publish("four", "bun@1.4.99"); + await syncContent(folder, content); + expect( + await operations.refreshBuilt(undefined, { registryUnavailable: true }), + ).toMatchObject([{ module: "site", result: "built" }]); + expect(distribution.asked).toEqual(asked); + expect((await fixture.page()).text).toBe("

four

"); + } finally { + await operations.stop("alpha/site"); + await operations.close(); + } + }, + 120_000, +); + posixTest( "a data-only pull rebuilds: the Launchpad watches the data repository its build reads, and the new build of the same module commit is swapped in", async () => { diff --git a/tests/update-everything.test.ts b/tests/update-everything.test.ts new file mode 100644 index 00000000..a2e58bca --- /dev/null +++ b/tests/update-everything.test.ts @@ -0,0 +1,963 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createOpenSync, openedModule } from "../src/content/open-sync"; +import type { SyncEntry, SyncRequest } from "../src/content/sync"; +import { + type BunInstaller, + createModuleBunResolver, + type ModuleBunResolver, + type RuntimeReason, +} from "../src/modules/bun-runtime"; +import type { BuiltRefresh } from "../src/modules/module-operations"; +import { PreparationRefused } from "../src/modules/preparation-refusal"; +import type { + Catalog, + CatalogOrganization, +} from "../src/organizations/catalog"; +import { + createUpdateJobs, + everythingText, + type UpdateItem, + type UpdateLine, + type UpdateParts, + updateEverything, +} from "../src/update/everything"; + +// One `lazurio update` (root decision 0212 points 3 and 4, decision F9's +// second amendment of 2026-10-24) without processes: the order of its parts, +// that a failing part never stops another, the content's wait for another +// content operation, the tools part's pre-warm of the Bun the moved modules +// pin (with the resolver a start uses and Bun's installer as a fake), the +// Launchpad's job that a second click joins, the words of the CLI, and the +// Open of one module. The parts' own cores run for real in +// tests/module-built.test.ts. + +const at = "2026-10-24T12:00:00.000Z"; +const entry = (path: string, extra: Partial = {}): SyncEntry => ({ + organization: "Alpha", + root: "organizations/alpha", + path, + kind: path === "." ? "root" : "module", + branch: "main", + result: "current", + at, + ...extra, +}); +const built = (extra: Partial = {}): BuiltRefresh => ({ + organization: "alpha", + module: "site", + app: "app/package.json", + result: "built", + commit: "b".repeat(40), + ...extra, +}); +const moved = entry("workspace/site", { + result: "fast-forwarded", + from: "a".repeat(40), + to: "b".repeat(40), +}); +const current = { + part: "product", + result: "current", + version: "0.1.12", +} as const; +/** Pins that were all read. */ +const readable = (pins: readonly string[]) => ({ pins, unreadable: 0 }); +/** A resolver for a part that must not resolve anything. */ +const unasked: ModuleBunResolver = async () => { + throw new Error("Not asked"); +}; + +test("the parts run in order, content, tools and builds before Lazurio itself, and each line is heard under way and then with its answer", async () => { + const order: string[] = []; + const seen: UpdateLine[][] = []; + const items = await updateEverything( + { + content: async (progress) => { + order.push("content"); + const where = { + organization: "Alpha", + root: "organizations/alpha", + path: "workspace/site", + kind: "module", + branch: "main", + } as const; + progress({ ...where, step: "checking" }); + progress({ ...where, step: "fetching", to: "b".repeat(40) }); + progress({ ...where, step: "done", entry: moved }); + return { + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [entry("."), moved], + }; + }, + builds: async (progress) => { + order.push("builds"); + progress({ + organization: "alpha", + module: "site", + app: "app/package.json", + step: "building", + commit: "b".repeat(40), + }); + return [built()]; + }, + tools: { + pins: async () => { + order.push("tools"); + return readable([]); + }, + resolve: unasked, + }, + product: async () => { + order.push("product"); + return current; + }, + }, + (lines) => seen.push([...lines]), + ); + expect(order).toEqual(["content", "tools", "builds", "product"]); + expect(items).toEqual([ + { part: "content", ...moved }, + { part: "content", ...entry(".") }, + { part: "build", ...built() }, + current, + ]); + // The module's line: checking, the newer commit while it is fetched, its + // answer; then its build under way, and Lazurio's check. + const site = seen.flatMap((lines) => + lines.flatMap((line) => + line.part === "content" && + "path" in line && + line.path === "workspace/site" + ? [ + "step" in line + ? `${line.step}${line.to === undefined ? "" : ` ${line.to.slice(0, 7)}`}` + : line.result, + ] + : [], + ), + ); + expect([...new Set(site)]).toEqual([ + "checking", + "fetching bbbbbbb", + "fast-forwarded", + ]); + for (const part of ["build", "product"]) + expect( + seen.some((lines) => + lines.some((line) => line.part === part && "step" in line), + ), + ).toBe(true); +}); + +test("a part that fails is one item and never stops the next: content's answers stay when Lazurio fails, and Lazurio is updated when content fails", async () => { + const parts: UpdateParts = { + content: async () => ({ + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [entry(".")], + }), + builds: async () => [built()], + product: async () => { + throw new Error("network"); + }, + }; + expect(await updateEverything(parts)).toEqual([ + { part: "content", ...entry(".") }, + { part: "build", ...built() }, + { part: "product", result: "failed", reason: "operation-failed" }, + ]); + expect( + await updateEverything({ + ...parts, + content: async () => { + throw new Error("git"); + }, + builds: async () => { + throw new Error("bun"); + }, + product: async () => ({ + part: "product", + result: "updated", + from: "0.1.11", + to: "0.1.12", + }), + }), + ).toEqual([ + { part: "content", result: "failed", reason: "operation-failed" }, + { part: "build", result: "failed", reason: "operation-failed" }, + { part: "product", result: "updated", from: "0.1.11", to: "0.1.12" }, + ]); + // A content part that cannot run here says why. + expect( + await updateEverything({ + content: async () => ({ + kind: "not-allowed", + reason: "not-for-this-environment", + }), + builds: async () => [], + product: async () => current, + }), + ).toEqual([ + { part: "content", result: "not-run", reason: "not-for-this-environment" }, + current, + ]); +}); + +test("content waits for another content operation a while, then says it was busy; the rest runs either way", async () => { + let calls = 0; + const slept: number[] = []; + const free = await updateEverything( + { + content: async () => { + calls += 1; + return calls < 3 + ? { kind: "busy" } + : { + kind: "content-sync", + at, + organizations: [], + entries: [entry(".")], + }; + }, + builds: async () => [], + product: async () => current, + }, + undefined, + { + busyPollMs: 5, + sleep: async (ms) => { + slept.push(ms); + }, + }, + ); + expect(calls).toBe(3); + expect(slept).toEqual([5, 5]); + expect(free[0]).toEqual({ part: "content", ...entry(".") }); + expect( + await updateEverything( + { + content: async () => ({ kind: "busy" }), + builds: async () => [], + product: async () => current, + }, + undefined, + { busyWaitMs: 0 }, + ), + ).toEqual([ + { part: "content", result: "not-run", reason: "content-busy" }, + current, + ]); +}); + +// ---- The tools part: the Bun the moved modules pin (decision F62) ----------- + +// The resolver a start uses (`createModuleBunResolver`), with #626's seams: a +// fixed system, plenty of space, its own map of fetches under way, and Bun's +// installer as a fake that lays out, for each version, a program reporting +// it, or refuses as told. The user's Bun is the one running these tests. No +// network. +const supported = ["darwin", "linux"].includes(process.platform); +const posixTest = test.skipIf(!supported); +let scratch = ""; +beforeAll(async () => { + if (supported) + scratch = await realpath(await mkdtemp(join(tmpdir(), "update-tools-"))); +}); +afterAll(async () => { + if (scratch) await rm(scratch, { recursive: true, force: true }); +}); + +async function runtimes( + name: string, + refuse: (version: string) => RuntimeReason | undefined = () => undefined, +) { + const home = join(scratch, name); + await mkdir(home, { recursive: true, mode: 0o700 }); + /** Every version Bun's installer was asked for, in order. */ + const asked: string[] = []; + const install: BunInstaller = async (input) => { + asked.push(input.version); + const refused = refuse(input.version); + if (refused !== undefined) return { kind: "refused", reason: refused }; + const program = join( + input.directory, + "node_modules", + input.packageName, + "bin", + "bun", + ); + await mkdir(dirname(program), { recursive: true }); + await writeFile(program, `#!/bin/sh\necho '${input.version}'\n`); + await chmod(program, 0o755); + return { kind: "installed" }; + }; + const resolve = createModuleBunResolver({ + home, + userBun: process.execPath, + path: "/usr/bin:/bin", + install, + system: async () => ({ + platform: "linux", + arch: "arm64", + libc: "glibc", + avx2: null, + }), + freeBytes: async () => 10 * 1024 ** 3, + fetches: new Map(), + }); + return { resolve, asked }; +} + +/** The tools part's items of an answer. */ +const toolsOf = (items: readonly UpdateItem[]) => + items.filter((item) => item.part === "tools"); + +/** One run whose content fast-forwarded site, with these pins; `heard` + * collects what its builds were told about the registry. */ +const runWith = ( + pins: readonly string[], + resolve: ModuleBunResolver, + heard: boolean[] = [], +) => + updateEverything({ + content: async () => ({ + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [moved], + }), + tools: { pins: async () => readable(pins), resolve }, + builds: async (_progress, run) => { + heard.push(run.registryUnavailable); + return []; + }, + product: async () => current, + }); + +posixTest( + "tools come after content and before any build: the Bun a moved module's apps pin and the cache does not hold is fetched before the build that needs it", + async () => { + const { resolve, asked } = await runtimes("before-build"); + const order: string[] = []; + const checkouts: unknown[] = []; + const seen: UpdateLine[][] = []; + const items = await updateEverything( + { + content: async () => ({ + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [ + entry(".", { result: "fast-forwarded", to: "c".repeat(40) }), + moved, + entry("workspace/site/db", { + kind: "data", + result: "fast-forwarded", + to: "d".repeat(40), + }), + entry("workspace/docs"), + ], + }), + tools: { + pins: async (moving) => { + checkouts.push(moving); + order.push("tools"); + return readable(["bun@1.4.99", "bun@1.4.99"]); + }, + resolve, + }, + builds: async () => { + // What the installer was asked for by the time the build starts. + order.push(`builds after [${asked.join(", ")}]`); + return [built()]; + }, + product: async () => current, + }, + (lines) => seen.push([...lines]), + ); + // Only the module checkouts this run fast-forwarded: not the root, a + // data mount or a module that was current. + expect(checkouts).toEqual([ + [ + { + organization: "Alpha", + root: "organizations/alpha", + path: "workspace/site", + }, + ], + ]); + expect(order).toEqual(["tools", "builds after [1.4.99]"]); + expect(asked).toEqual(["1.4.99"]); + expect(items.map((item) => item.part)).toEqual([ + "content", + "content", + "content", + "content", + "tools", + "build", + "product", + ]); + expect(toolsOf(items)).toEqual([ + { part: "tools", packageManager: "bun@1.4.99", result: "fetched" }, + ]); + // The pin's line: under way, then its answer. + expect([ + ...new Set( + seen.flatMap((lines) => + lines.flatMap((line) => + line.part === "tools" && "packageManager" in line + ? ["step" in line ? line.step : line.result] + : [], + ), + ), + ), + ]).toEqual(["fetching", "fetched"]); + // The next run finds it in the cache and fetches nothing. + expect(toolsOf(await runWith(["bun@1.4.99"], resolve))).toEqual([ + { part: "tools", packageManager: "bun@1.4.99", result: "cached" }, + ]); + expect(asked).toEqual(["1.4.99"]); + // Nothing moved: no pins are read and nothing is resolved. + let read = false; + const still = await updateEverything({ + content: async () => ({ + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [entry("."), entry("workspace/site")], + }), + tools: { + pins: async () => { + read = true; + return readable(["bun@1.4.98"]); + }, + resolve: unasked, + }, + builds: async () => [], + product: async () => current, + }); + expect(read).toBe(false); + expect(toolsOf(still)).toEqual([]); + }, +); + +posixTest( + "offline: the first runtime-unavailable ends the pre-warm for the run; after it each pin is only looked up without the network, cached when the user's Bun or the cache has it, otherwise not fetched", + async () => { + let offline = false; + const { resolve, asked } = await runtimes("offline", () => + offline ? "runtime-unavailable" : undefined, + ); + // An earlier run fetched 1.4.96. + await runWith(["bun@1.4.96"], resolve); + expect(asked).toEqual(["1.4.96"]); + offline = true; + const items = await runWith( + ["bun@1.4.98", "bun@1.4.96", "bun@1.4.99", `bun@${Bun.version}`], + resolve, + ); + // One attempt at the registry in this run, none after it. + expect(asked).toEqual(["1.4.96", "1.4.98"]); + expect(toolsOf(items)).toEqual([ + { + part: "tools", + packageManager: "bun@1.4.98", + result: "refused", + reason: "runtime-unavailable", + }, + { part: "tools", packageManager: "bun@1.4.96", result: "cached" }, + { + part: "tools", + packageManager: "bun@1.4.99", + result: "not-fetched", + reason: "runtime-unavailable", + }, + { + part: "tools", + packageManager: `bun@${Bun.version}`, + result: "cached", + }, + ]); + // The rest of the run goes on. + expect(items.at(-1)).toEqual(current); + }, +); + +posixTest( + "one run, one answer about the network: after runtime-unavailable the builds of that run are told the registry does not answer; no other refusal tells them, and the next run asks again", + async () => { + let refuse: RuntimeReason | undefined = "runtime-unavailable"; + const { resolve } = await runtimes("one-answer", () => refuse); + const heard: boolean[] = []; + await runWith(["bun@1.4.98"], resolve, heard); + expect(heard).toEqual([true]); + // Held for that run only: the next one asks the registry again. + refuse = undefined; + await runWith(["bun@1.4.98"], resolve, heard); + expect(heard).toEqual([true, false]); + // Only runtime-unavailable: a full disk is not an answer about the + // network. + refuse = "runtime-disk-full"; + await runWith(["bun@1.4.97"], resolve, heard); + expect(heard).toEqual([true, false, false]); + // Nothing moved, no pre-warm: no answer, the builds ask themselves. + await updateEverything({ + content: async () => ({ + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [entry("workspace/site")], + }), + tools: { pins: async () => readable(["bun@1.4.95"]), resolve }, + builds: async (_progress, run) => { + heard.push(run.registryUnavailable); + return []; + }, + product: async () => current, + }); + expect(heard).toEqual([true, false, false, false]); + }, +); + +posixTest( + "every other refusal is an item with its reason and the pre-warm goes on; a download that did not verify is a warning", + async () => { + const { resolve, asked } = await runtimes("refusals", (version) => + version === "1.4.96" + ? "runtime-integrity-failed" + : version === "1.4.97" + ? "runtime-version-unknown" + : version === "1.4.98" + ? "runtime-disk-full" + : undefined, + ); + const items = await runWith( + ["bun@1.4.96", "bun@1.4.97", "bun@1.4.98", "bun@1.4.99"], + resolve, + ); + expect(asked).toEqual(["1.4.96", "1.4.97", "1.4.98", "1.4.99"]); + expect(toolsOf(items)).toEqual([ + { + part: "tools", + packageManager: "bun@1.4.96", + result: "refused", + reason: "runtime-integrity-failed", + warning: true, + }, + { + part: "tools", + packageManager: "bun@1.4.97", + result: "refused", + reason: "runtime-version-unknown", + }, + { + part: "tools", + packageManager: "bun@1.4.98", + result: "refused", + reason: "runtime-disk-full", + }, + { part: "tools", packageManager: "bun@1.4.99", result: "fetched" }, + ]); + }, +); + +posixTest( + "the user's own Bun is not fetched: a pin of exactly that version is there already", + async () => { + const { resolve, asked } = await runtimes("user-bun"); + expect(toolsOf(await runWith([`bun@${Bun.version}`], resolve))).toEqual([ + { + part: "tools", + packageManager: `bun@${Bun.version}`, + result: "cached", + }, + ]); + expect(asked).toEqual([]); + }, +); + +test("the update never fails because of tools: pins that cannot be read and a resolver that throws are items, and builds and Lazurio run either way", async () => { + const builds: string[] = []; + const parts = (tools: NonNullable): UpdateParts => ({ + content: async () => ({ + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [moved], + }), + tools, + builds: async () => { + builds.push("ran"); + return [built()]; + }, + product: async () => current, + }); + expect( + await updateEverything( + parts({ + pins: async () => { + throw new Error("catalog"); + }, + resolve: unasked, + }), + ), + ).toEqual([ + { part: "content", ...moved }, + { part: "tools", result: "failed", reason: "operation-failed" }, + { part: "build", ...built() }, + current, + ]); + // A resolver that throws something other than a refusal, and one that + // refuses: each its item, never a throw. + const items = await updateEverything( + parts({ + pins: async () => readable(["bun@1.4.98", "bun@1.4.99"]), + resolve: async ({ packageManager, owner }) => { + if (packageManager === "bun@1.4.98") throw new Error("broken"); + throw new PreparationRefused( + "runtime-unsupported-platform", + join(owner, "package.json"), + "Pinned Bun runtime unavailable", + ); + }, + }), + ); + expect(toolsOf(items)).toEqual([ + { + part: "tools", + packageManager: "bun@1.4.98", + result: "refused", + reason: "operation-failed", + }, + { + part: "tools", + packageManager: "bun@1.4.99", + result: "refused", + reason: "runtime-unsupported-platform", + }, + ]); + expect(items.at(-2)).toEqual({ part: "build", ...built() }); + expect(items.at(-1)).toEqual(current); + expect(builds).toEqual(["ran", "ran"]); + // A moved app whose pin cannot be read: the pins that were read are + // pre-warmed, then the part is one failed item, never silently clean. + const unreadable = await updateEverything( + parts({ + pins: async () => ({ pins: ["bun@1.4.97"], unreadable: 1 }), + resolve: async () => ({ + executable: process.execPath, + binDir: null, + source: "fetched", + }), + }), + ); + expect(toolsOf(unreadable)).toEqual([ + { part: "tools", packageManager: "bun@1.4.97", result: "fetched" }, + { part: "tools", result: "failed", reason: "operation-failed" }, + ]); + expect(unreadable.at(-1)).toEqual(current); + expect(builds).toEqual(["ran", "ran", "ran"]); +}); + +test("the Launchpad's job: one run at a time, a second start joins it, the newest stays readable after it ended", async () => { + let finish: () => void = () => {}; + let report: (lines: readonly UpdateLine[]) => void = () => {}; + let runs = 0; + const jobs = createUpdateJobs({ + run: (onChange) => { + runs += 1; + report = onChange; + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + expect(jobs.latest()).toBeNull(); + const first = jobs.start(); + expect(first.joined).toBe(false); + expect(jobs.latest()).toEqual({ id: first.job, state: "running", lines: [] }); + report([{ part: "product", step: "checking" }]); + expect(jobs.start()).toEqual({ job: first.job, joined: true }); + expect(runs).toBe(1); + expect(jobs.latest()?.lines).toEqual([{ part: "product", step: "checking" }]); + report([current]); + finish(); + await jobs.settled(); + expect(jobs.latest()).toEqual({ + id: first.job, + state: "succeeded", + lines: [current], + }); + const third = jobs.start(); + expect(third.joined).toBe(false); + expect(third.job).not.toBe(first.job); + expect(runs).toBe(2); + finish(); + await jobs.settled(); + // A run that throws still ends. + const failing = createUpdateJobs({ + run: () => Promise.reject(new Error("x")), + }); + failing.start(); + await failing.settled(); + expect(failing.latest()?.state).toBe("succeeded"); +}); + +test("the CLI's words: what moved, what is left and a summary, what was built or not; nothing where there was nothing to do", () => { + const items: UpdateItem[] = [ + { part: "content", ...entry(".") }, + { part: "content", ...moved }, + { + part: "content", + ...entry("workspace/docs", { result: "blocked", reason: "dirty" }), + }, + { part: "content", ...entry("workspace/gone", { result: "absent" }) }, + { part: "build", ...built() }, + { + part: "build", + ...built({ + module: "blog", + result: "failed", + reason: "build-script-failed", + }), + }, + { part: "build", ...built({ module: "docs", result: "current" }) }, + ]; + expect(everythingText(items)).toEqual([ + `organizations/alpha/workspace/site (main): fast-forwarded ${"a".repeat(12)}..${"b".repeat(12)}`, + "organizations/alpha/workspace/docs (main): blocked dirty, left as it is", + "Content: 4 repositories checked, 1 fast-forwarded, 1 left as they are.", + `Built alpha/site (app/package.json) at ${"b".repeat(12)}.`, + "alpha/blog (app/package.json) was not built (build-script-failed); what runs stays as it was.", + ]); + expect( + everythingText([ + { part: "content", result: "not-run", reason: "not-installed" }, + { + part: "content", + result: "not-run", + reason: "not-for-this-environment", + }, + ]), + ).toEqual([]); + expect( + everythingText([ + { part: "content", result: "not-run", reason: "content-busy" }, + ]), + ).toEqual([ + "Content was not synchronized: another content operation kept the Folder busy (content-busy). Run lazurio update again.", + ]); + // The tools part: what was fetched or not, the warning first in its line, + // nothing for a Bun that was there already. + expect( + everythingText([ + { part: "content", ...moved }, + { part: "tools", packageManager: "bun@1.4.99", result: "fetched" }, + { part: "tools", packageManager: `bun@${Bun.version}`, result: "cached" }, + { + part: "tools", + packageManager: "bun@1.4.96", + result: "refused", + reason: "runtime-integrity-failed", + warning: true, + }, + { + part: "tools", + packageManager: "bun@1.4.97", + result: "refused", + reason: "runtime-version-unknown", + }, + { + part: "tools", + packageManager: "bun@1.4.98", + result: "refused", + reason: "runtime-unavailable", + }, + { + part: "tools", + packageManager: "bun@1.4.95", + result: "not-fetched", + reason: "runtime-unavailable", + }, + { part: "tools", result: "failed", reason: "operation-failed" }, + ]).slice(2), + ).toEqual([ + "Fetched Bun 1.4.99 for the apps that pin it.", + "Warning: Bun 1.4.96 did not verify and was discarded (runtime-integrity-failed).", + "Bun 1.4.97 was not fetched (runtime-version-unknown).", + "Bun 1.4.98 was not fetched: the registry did not answer (runtime-unavailable).", + "Bun 1.4.95 was not tried: the registry did not answer this run (runtime-unavailable).", + "The Bun the moved modules pin could not all be read (operation-failed); a start or build of their apps names why.", + ]); +}); + +// ---- The Open of one module --------------------------------------------------- + +const module = { + organization: "Alpha", + root: "organizations/alpha", + path: "workspace/site", +} as const; + +function openWorld(record: readonly SyncEntry[] | null = null) { + let clock = 1_000_000; + const requests: SyncRequest[] = []; + const pending: (() => void)[] = []; + const sync = createOpenSync({ + now: () => clock, + record: async () => record, + sync: (request) => { + requests.push(request); + return new Promise((resolve) => { + pending.push(() => + resolve({ + kind: "content-sync", + at, + organizations: ["Alpha"], + entries: [], + }), + ); + }); + }, + }); + return { + sync, + requests, + finish: () => { + for (const done of pending.splice(0)) done(); + }, + advance: (ms: number) => { + clock += ms; + }, + }; +} + +test("an Open synchronizes exactly that module in the background, once per module in 5 minutes", async () => { + const world = openWorld(); + // Returns at once: the answer never waits for the synchronization, which + // is still under way here. + expect(world.sync.opened(module)).toBeUndefined(); + await Bun.sleep(0); + expect(world.requests).toEqual([ + { organization: "Alpha", repository: "workspace/site" }, + ]); + // Opened again, and again through the gateway's ensure: nothing more. + world.sync.opened(module); + world.advance(4 * 60_000); + world.sync.opened({ ...module, organization: "alpha" }); + await Bun.sleep(0); + expect(world.requests).toHaveLength(1); + // Another module is its own. + world.sync.opened({ ...module, path: "workspace/docs" }); + await Bun.sleep(0); + expect(world.requests.at(-1)).toEqual({ + organization: "Alpha", + repository: "workspace/docs", + }); + // Five minutes after the first: once more. + world.advance(60_000); + world.sync.opened(module); + await Bun.sleep(0); + expect(world.requests).toHaveLength(3); + world.finish(); + await world.sync.settled(); +}); + +test("an Open never synchronizes a checkout the last round left blocked, nor one of a blocked root; the hourly round tries those again", async () => { + for (const blocked of [ + entry("workspace/site", { result: "blocked", reason: "dirty" }), + entry(".", { result: "blocked", reason: "wrong-branch" }), + ]) { + const world = openWorld([blocked]); + world.sync.opened(module); + await world.sync.settled(); + world.advance(10 * 60_000); + world.sync.opened(module); + await world.sync.settled(); + expect(world.requests).toEqual([]); + } + // Another module's or another Organization's blocked checkout is not this + // one's. + const world = openWorld([ + entry("workspace/docs", { result: "blocked", reason: "dirty" }), + entry(".", { + organization: "Beta", + root: "organizations/beta", + result: "blocked", + reason: "dirty", + }), + ]); + world.sync.opened(module); + await Bun.sleep(0); + expect(world.requests).toHaveLength(1); + world.finish(); + await world.sync.settled(); +}); + +test("the module an answer names, as the synchronization names it: by the Organization's GitHub login, never the Personalspace", () => { + const group = ( + directory: string, + organization: string, + forgeLogin?: string, + ): CatalogOrganization => ({ + directory, + organization, + displayName: organization, + ...(forgeLogin === undefined ? {} : { forgeLogin }), + state: "current", + issues: [], + executable: true, + teams: [], + repositories: [], + modules: [ + { + organization, + module: "site", + path: "workspace/site", + teams: ["workspace"], + teamsSource: "default", + apps: [], + defaultApp: null, + state: "current", + executable: true, + }, + ], + }); + const catalog: Catalog = { + kind: "catalog", + organizations: [ + group("alpha_GEN3", "alpha", "Alpha"), + group("beta", "beta"), + ], + personalspace: group("personalspace", "personalspace", "someone"), + }; + expect( + openedModule(catalog, { organization: "alpha", module: "site" }), + ).toEqual({ + organization: "Alpha", + root: "organizations/alpha_GEN3", + path: "workspace/site", + }); + expect( + openedModule(catalog, { organization: "alpha", module: "other" }), + ).toBeNull(); + expect( + openedModule(catalog, { organization: "beta", module: "site" }), + ).toBeNull(); + expect( + openedModule(catalog, { organization: "personalspace", module: "site" }), + ).toBeNull(); +});