Skip to content

Move the std Plugin to its own repository and load it as a library - #209

Merged
vinniefalco merged 12 commits into
cppalliance:masterfrom
vinniefalco:master
Nov 11, 2026
Merged

vinniefalco merged 12 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

@vinniefalco vinniefalco commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

The file tools now come from a dynamic library, the std Plugin, which Workshop loads when it starts. The code of that Plugin moved out of this repository to cppalliance/runvil-plugin-std, and crates/plugin-std is deleted. Twelve commits, 145 files. Most of the deleted lines are that crate.

For a library to offer real tools, the Harness's loader had to learn more of the C contract in cppalliance/runvil-plugin-api. It now speaks contract 0.4: a library Plugin can insert Packs, run a tool call in steps, and read a run's files.

The first commit is the one this pull request began with. It removes the non-Unix set_executable stub, so build-workshop passes clippy on Windows. The last three commits fix a flaky check in the Windows installer test and put a time limit on the package-install steps.

The loader (crates/harness-internal/plugin-loader):

  • A library Plugin's Packs go into the PackRegistry. Each run gets its own instance of a Pack, and the Plugin stays loaded until the last instance is gone.
  • A tool call runs as a series of steps. A run can be canceled between two steps, and the loader then tells the Plugin to free the call's state.
  • A step reads files only through file_stat, file_list, and file_read, which the loader answers from the tool call's Access. So the run's policy applies to every read. A read the policy denies reaches the Plugin as a file error with the policy's own reason, and it does not fail the call.
  • A panic in a policy or a backend under one of those functions becomes a file error, because a panic must not unwind through a C function.
  • Each byte limit the loader enforces is checked at compile time against the contract's RV_MAX_ constant.
  • PackOrigin::Linked is renamed PackOrigin::Plugin. That is the one change to crates/runvil/public-api.txt.

Workshop:

  • Workshop loads every library in its Plugin directory when it starts. [agents] plugins in workshop.toml names another directory instead of the installation's own.
  • Nothing links the std Plugin. crates/workshop/plugin-std is a small crate that builds the library with every Workshop build, from the commit the root Cargo.toml pins. cargo workshop copies it into plugins beside the development build.
  • runvil-workshop --plugins prints each Plugin the installation loads and exits.
  • A Workshop with no std library refuses an agent that requires std and says that runvil/std is not installed.

Installers:

  • Windows: the NSIS setup installs plugins\plugin_std.dll with the Workshop component and removes it with it.
  • macOS and Linux: Tauri copies the library into Workshop's bundle, at Runvil.app/Contents/Resources/plugins or at usr/lib/runvil/plugins in the AppImage. The updater replaces only that bundle, so the library must be inside it to change with Workshop. The installer build fails when the bundle lacks the library.
  • cell.sh and cell.ps1 check that the library is installed and run --plugins, which must list std.

The workflows:

  • The update-install check in cell.ps1 was flaky, and it is fixed here. The cell stopped the Gateway and then listed the model files. Windows keeps a stopped process in its list, with its files still open, while it tears the process down, and Wait-Process returns before that ends. So the listing was sometimes taken while the stopped Gateway still held its Speech to Text download open, and it showed a stale length. The cell now waits until a fresh process listing shows no Workshop and no Gateway, reads each file's length from the file itself, and compares only after two readings agree. This flaw was in the cell before this pull request and does not come from the std Plugin.
  • The four steps that run apt-get install, in ci.yml and installers.yml, have a 20 minute limit. They normally take under a minute. When the Ubuntu mirrors do not answer, apt-get waits for a long time, and two of those jobs had no limit of their own.

Things a reviewer should know:

  • The pins move together. A Plugin loads only in a Harness that speaks its exact contract version, so runvil-plugin-sys, runvil-plugin-sdk, and std-plugin in the root Cargo.toml name commits of one contract. They are at runvil-plugin-api 03a9fa3, tagged api-v0.4.0, and at runvil-plugin-std 782ad23.
  • Two tool behaviors differ from the crate this removes. ListDir leaves out an entry whose name holds a control character and counts it in a note. Grep counts directories toward the work it does between two steps. Read, ListDir, and Grep are otherwise as they were, and Write, StrReplace, Delete, and Glob are still stubs.
  • A config that sets [agents] plugins loads only that directory, so that directory must hold the std library too.
  • Workshop installs no tracing subscriber, so the warnings about a library that does not load are written nowhere. --plugins is the way to see them.

Checks:

  • Locally on Windows, at the commit before the last four, which change only cell.ps1 and the two workflow files: rustfmt, both clippy commands with warnings denied, both doc commands with -D warnings, cargo nextest (4106 and 497 tests), cargo test for build-xtask and build-workshop, docs-claims.mjs, cargo deny check, cargo xtask api --check, and cargo dylint.
  • CI on this pull request is green at the head.
  • The Installers workflow on this pull request passes at the head: all five builds and all ten install cells of its pull-request mode. On Windows, on macOS for both architectures, and on Linux for both architectures, the installed Workshop prints std 0.1.0 for --plugins.
  • The full mode of the Installers workflow, dispatched on the fork at 3e5a260c0, passes with all seventeen install cells. The three commits after it change only cell.ps1 and the two workflow files.
  • The flaky check: with the old script, test (windows, all) failed 2 times in 6 runs on one commit. With the script at the head it passed 11 times in a row, the run itself and ten re-runs of the job.
  • Two earlier attempts of the runs at the head failed in apt-get, because the Ubuntu package mirrors did not resolve for a few hours on the day of this change. That outage is why the package-install limit is here.

Not checked:

  • No run exercised the updater itself. Every cell installs fresh, so "the updater replaces the library with Workshop" rests on where the library sits, not on an update that was run.
  • No test here runs a real Tauri build, so the configuration that copies the library into the bundle is proven only by the CI builds above.
  • The fix for the flaky check rests on eleven passes and on the cause found in the cell's own output. The check has no test that forces the slow teardown.

`copy_executable` now sets mode `0755` itself, in a `#[cfg(unix)]` block, and both `set_executable` functions are gone. The non-Unix one returned `io::Result<()>` but could only return `Ok(())`.

- On Unix the mode and the error text stay the same.
- The diff changes no test.
@vinniefalco vinniefalco changed the title Put Timestamp at the facade root and use it in RunMeta Make build-workshop pass clippy on Windows Nov 11, 2026
Workshop loads each dynamic library in the directory that `[agents] plugins` names as a Plugin, and the desktop app names the `plugins` directory of its own installation when the config names none. `workshop-plugin-std` builds the std Plugin, pinned from its own repository, as a dynamic library, and the server tests load that library.

- `installed_plugin_dir` puts the directory beside the executable, beside `Runvil.app`, or beside `Runvil.AppImage`, which is the layout `installed_gateway` reads.
- `AgentSessions` keeps the `PluginReport` in `_plugins`, so each library Plugin stays loaded while the launcher lives.
- `load_plugin_dir` returns `None` for a directory that does not exist and logs every other failure. One library that fails does not stop the others.
- `tidy.rs` gains the `PLUGINS` tier, which only `workshop-server` may name.
- `cargo workshop` and the installers do not create or copy a `plugins` directory.
The loader speaks contract 0.3. A Plugin's entry point now inserts Packs into the `PackRegistry`, each with an `EntryFactory` that sends `RV_PACK_NEW` for each run, and `Steps` sends one `RV_TOOL_CALL` each time its future is polled.

- `session.rs` holds the open call of a thread for the three verbs that have arguments. `Session`, `answer`, and the string checks moved there from `host.rs`.
- `EntryFactory` and `EntryPack` each hold an `Arc<Plugin>`, and `Steps` borrows its `EntryPack`. So `RV_PACK_DELETE` comes after the calls of an instance, and `RV_PLUGIN_UNLOAD` comes after each `RV_PACK_DELETE`.
- A step that returns `RV_PENDING` wakes the task of its future, because the contract has no wake function. `Steps` sends `RV_TOOL_CANCEL` when a pending call fails or is dropped.
- `insert_packs` leaks the name and the description of each Pack with `String::leak`, because `PackInfo` borrows `&'static str`.
- A Pack that the registry refuses is a failure with the `Reason` fact `pack`. `insert_plugin` returns the first such failure and leaves the earlier Packs in the registry.
- `PackOrigin::Linked` becomes `PackOrigin::Plugin`, because a Pack from a dynamic library is not linked.
- `harness-test-plugin` inserts the Pack `fixture/test` with the tools `hello`, `steps`, and `never`. `tests/it/run.rs` runs them through `Harness`, and cancels a run whose call is pending.
- The root `Cargo.toml` pins `runvil-plugin-sys`, `runvil-plugin-sdk`, and `std-plugin` at commits of contract 0.3.
`with_plugins` now loads the Plugin directory before the linked std Plugin, and calls `plugin_std::install` only when no library Plugin inserted `runvil/std`. A server test runs a prompt that calls `std/hello`, a tool that only the std Plugin's library has.

- A Host with no Plugin directory, or one whose std library does not load, keeps the linked std Pack, so the built-in chat agent still finds its file tools.
- The library's Pack has the one tool `hello` and no file tools. No build places the library in a Plugin directory, so no shipped Workshop loads it.
A tool of a library Plugin must read the files of its run under the mode and the policy of the run. The loader now speaks contract 0.4: each step carries `file_stat`, `file_list`, and `file_read`, and each one reads through the `Access` of the tool call of the step. The fixture Plugin gets the tools `stat`, `list`, and `read`, and a prompt reads the files of a run through them.

- `Session` holds the access as a `StepAccess` pointer that `open_step` sets and clears, and `answer_file` is the only code that reads through it. `StepAccess` implements `UnwindSafe` and `RefUnwindSafe`, so the auto traits of `Plugin` do not change.
- `Lent` keeps what the last file function handed back. The next file function replaces it, and the end of the step frees it.
- `describe` gives each `VfsError` a kind and a text. A policy denial carries the reason of the policy, an I/O failure carries its kind and the description from the system, and an error without a kind of its own loses its leading label.
- A file function that fails returns `RV_ERR_FILE`, and the call continues. A path or an out pointer that breaks the contract is a misuse, and the call fails.
- `file_read` limits `len` to `usize::MAX`, because a backend that slices a whole file refuses a longer length.
- The misuse text for a wrong `ctx` now says `a tool call function`.
- The pins of `runvil-plugin-sys`, `runvil-plugin-sdk`, and `std-plugin` move together, because one build links one version of the contract.
- The fakes `dropped` and `stateless` are new. Each replaces a fake that two tests shared, whose counters one test changed while the other read them.
- No test reads through a policy that denies. `describe` is tested on `VfsError` values.
Workshop now gets the file tools from the dynamic library of the std Plugin alone, so the crate `crates/plugin-std` and the linked fallback in `with_plugins` are gone. The server depends on `workshop-plugin-std`, whose library is now named `plugin_std`, so each Workshop build writes the library to `deps`. `cargo workshop` and the installers copy it from there into `plugins` beside Workshop.

- `plugin.rs` in `build-workshop` names the library for a target and places it. A build that left no library fails with `cannot place the std Plugin beside Workshop`.
- No command changes: Cargo builds the library as a dependency of the server. The server does not link it.
- `installer.nsi` installs `plugins\plugin_std.dll` with the Workshop component, from `STDPLUGINSRCPATH`, and removes it with that component.
- A macOS or Linux installer puts the library in `payload/plugins`, and `ifw::stage` copies that directory into the data of `com.runvil.workshop`.
- `cell.ps1` and `cell.sh` require the library when Workshop is selected, and refuse it when Workshop is not.
- A Host with no Plugin directory has no `std` Pack. It refuses a prompt that requires `std` with `runvil/std is not installed`.
- `std_plugin_dir` gives the server tests one shared Plugin directory with a hard link to the library, because Windows cannot remove a library that a server still has loaded.
- `std_real_directory.rs` now runs the tools of the library over `RealBackend`, through the file functions of the loader.
- On macOS and Linux the updater replaces only the app, so it does not update `plugins`. `crates/workshop/desktop/README.md` records this.
- The NSIS template, the IFW package, and the two cell scripts did not run here: this machine has no Tauri CLI, no Qt IFW, and only a stub Gateway.
A panic in a policy or a backend under a Plugin's file function now becomes a file error, because a panic must not unwind through a C function. The Workshop server warns at startup when no library Plugin inserted `runvil/std`, and the NSIS setup stops when it cannot derive the path of `plugin_std.dll`. The pins of `runvil-plugin-sys`, `runvil-plugin-sdk`, and `std-plugin` move to their current commits.

- `contained` runs each access operation under `catch_unwind` and returns a `VfsError::Backend` that says "the file operation panicked".
- `describe` takes the text of a `VfsError::InvalidPath` from its `reason`, names only the `path` of a `VfsError::Conflict`, and gives a `VfsError::NotUtf8` a sentence of its own.
- `file_read` limits `offset` to `i64::MAX` and truncates the bytes it hands back to `len`.
- `std_plugin_dir` makes its hard link under a name of the test process and renames it over the old link, so the shared directory is never without the library.
- `leave_plugin` writes different bytes for each build, so a test shows which library a build placed.
- A run over a policy that denies and a policy that panics now reads through the fixture library: each read is a file error and the run goes on.
- No test covers the truncation of a read longer than `len`, and no test runs the NSIS guard.
The macOS and Linux updater replaces only `Runvil.app` or the AppImage, so a Plugin directory beside them was never updated with Workshop. The installer now has Tauri copy the std Plugin's library into the bundle, `installed_plugin_dir` names the directory there, and `runvil-workshop --plugins` lists what an installation loads.

- `plugin::bundle_config` passes `bundle.macOS.files` or `bundle.linux.appimage.files` to `tauri build` through `--config`. It does not use `bundle.resources`, because the desktop build script checks each resource before Cargo has built the library.
- `plugin::check_bundled` fails the installer build when `Runvil.app/Contents/Resources/plugins` or `Runvil.AppDir/usr/lib/runvil/plugins` lacks the library. The payload and the IFW package no longer carry `plugins`.
- `MACOS_PLUGIN_PARENT` and `APPIMAGE_PLUGIN_PARENT` in `peer.rs` name the two places, and a test of `build-workshop` requires its own copies to match.
- `list_plugin_dir` loads a directory, returns a `PluginListing`, and unloads it. Workshop installs no `tracing` subscriber, so this is the only place the result of a load shows.
- `cell.sh` and `cell.ps1` run `--plugins` and require a line for `std`. `cell.sh` also requires that no `plugins` directory sits beside Workshop.
- `field!` in the loader takes the contract's limit as a third argument, and the pins move to the commits that declare `RV_MAX_NAME` and the other limits.
- No test here runs a real Tauri build, so the two `files` keys are proven only by an installer run on macOS and Linux.
`cell.ps1` read nothing from `runvil-workshop.exe --plugins`, because PowerShell waits for a windowed program, and reads its output, only when that output is piped. The check now pipes the listing to `Out-String`, as the version check does, and a failure quotes what the program wrote to stderr.
@vinniefalco vinniefalco changed the title Make build-workshop pass clippy on Windows Move the std Plugin to its own repository and load it as a library Nov 11, 2026
The update-install check compared two directory listings of the model files, and a listing gives a stale length for a file that a process still writes. So a Speech to Text download that was still in progress at the first listing made the check fail through no act of the setup.

- `Get-Length` opens each file and reads its length, and `Wait-ModelState` returns only when two readings half a second apart agree.
- `Stop-Started` stops Workshop before the Gateway, because Workshop starts a Gateway when it finds none, and repeats until a fresh listing finds neither process.
- A failure of the check now names the Workshop and Gateway processes that still run.
- `Stop-Started` in `finally` prints its failure and does not replace the failure that came first.
Windows keeps a stopped process in its list, with its files still open, while it tears the process down, and `Wait-Process` returns before that ends. `Stop-Started` returned in that time, so the update-install check listed the model files while the stopped Gateway still held its download open, and the listing was stale. The same cell failed 2 times in 6 on one commit for that reason.

- `Stop-Started` now lists the processes again every 100 milliseconds and returns only when the list holds no Workshop and no Gateway. It fails after 120 seconds and names what still runs.
- It prints a line when the stop took one second or more.
When the Ubuntu mirrors do not answer, `apt-get` waits for a long time, and two of the jobs that run it have no time limit of their own. Each of the four steps that run `apt-get install` now has `timeout-minutes: 20`, so such a job fails in 20 minutes.

- The steps are `Install Tauri system packages` in `check-workshop-linux` and in `dylint`, and `Install the Linux build packages` and `Install the Linux test packages` in `installers.yml`.
- The limit does not make a step pass when the mirrors are down. The job still fails and needs a re-run.
@vinniefalco
vinniefalco merged commit fa65bdc into cppalliance:master Nov 11, 2026
242 of 252 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant