diff --git a/README.md b/README.md index 5337d93..c23ecd4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ You can see how the sausage is made. +**v3:** Read [Run Sausage v3 on Citadel](docs/RUNNING.md) for the console, approvals, and current bootstrap gaps. +The rest of this README describes v2. + Sausage is a world of small WASI programs ("plugins") that you grow by talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call to a plugin starts a new process. The agent is a plugin too: it writes new diff --git a/docs/RUNNING.md b/docs/RUNNING.md new file mode 100644 index 0000000..182520a --- /dev/null +++ b/docs/RUNNING.md @@ -0,0 +1,260 @@ +# Run Sausage v3 on Citadel + +The console is the UI. The browser handles credential approvals, not chat. + +**TL;DR:** Use a separate v3 world and the release host. Keep its complete folder between starts. +The development bundle now seeds a complete source tree, including agents, skills, and extensions. +The Nix package does not yet supply that bundle. Verify the remaining cutover steps below before production use. +The rest of the root README describes v2. + +## Build and start + +Run host commands from a checkout of the `v3` branch on Citadel. +Use Nix with flakes. The package supplies the native Rust builder and its tools. + +The package entry point is: + +```sh +nix run . -- run "$HOME/sausage-v3" --userland "$SAUSAGE_USERLAND" +``` + +`SAUSAGE_USERLAND` must name an assembled bundle with `bin/` and `src/` directories. +It is not a world folder. The CLI also accepts this variable without `--userland`. +The package does not set it for you. + +The source-tree bundle builder is: + +```sh +nix develop -c nu userland/bundle.nu +``` + +It builds the world commands and creates `target/userland/bundle` by default. +It snapshots committed source, not uncommitted edits. +The source archive includes the complete `agents/`, `skills/`, and `extensions/` directories. +The world copier preserves symlinks, including dangling links in the source tree. +A fresh development world no longer needs a manual source copy. + +For a release host from the checkout: + +```sh +nix develop -c cargo build -p sausage --release --locked +``` + +Cargo puts the host under `target/release/sausage`, unless `CARGO_TARGET_DIR` selects another directory. +Use that binary with the same `run WORLD --userland BUNDLE` arguments. +Use the repository's build slots and shared executor target directory for agent validation builds. + +Choose `$HOME/sausage-v3`, not the live v2 `~/world`. +For the actual cutover, let v2 finish its work before you stop it. +Do not run two production services. This guide does not stop or replace the deployed service. +The host creates this layout: + +| Host path | Purpose | +| --- | --- | +| `sausage-v3/world/` | The world root `/`, including programs and editable source. | +| `sausage-v3/host/` | Private host state, including credentials, harness storage, undo, and builder caches. | + +The host creates `/run` in memory. It generates `/proc` at runtime. +It copies a bundle only when `WORLD/world` does not exist. +A later `--userland` does not update an existing world. +Even a failed initial start can create `WORLD/world`. Do not assume another start will seed it. +Keep both `world/` and `host/` for recovery. Never copy secrets into the world. + +Sources: [`main.rs`](../host/src/main.rs), [`world.rs`](../host/src/world.rs), +[`flake.nix`](../flake.nix), and [`bundle.nu`](../userland/bundle.nu). + +## Use and stop the console + +Each nonempty console line submits a message to the durable root conversation. +Assistant text appears on stdout. This is not a shell or the v2 command dispatcher. +Text such as `quit`, `help`, and `core.restart` has no special host meaning. +Ask the agent to execute world commands when you need tools. + +Press **Ctrl-C** to stop the host. SIGTERM also requests orderly shutdown. +EOF only detaches the console input. It does not stop the host. +Keep the foreground terminal available if you want to continue chat. + +After a crash, repeat the same start command with the same world folder. +The host opens `host/harness.sqlite`, restores extensions, and resumes durable work. +It does not replay the historical transcript into the console. +It cannot restore ordinary process memory or reverse completed network effects. +There is no automatic restart supervisor in this guide. +The existing flake loop still contains v2 assumptions. Do not use its no-argument mode for v3. + +Sources: [`input.rs`](../host/src/harness/input.rs), [`main.ts`](../host/js/main.ts), +and [`harness/mod.rs`](../host/src/harness/mod.rs). + +## Reach the approval pages + +The host listens on `127.0.0.1:8081` by default. +If another service owns port 8081, choose another host port with `--api-listen`. +Adjust the tunnel's remote port, but retain laptop port 8081 for the default origin. +From the laptop, create an SSH tunnel to Citadel: + +```sh +ssh -N -L 8081:127.0.0.1:8081 citadel.vpn.surma.technology +``` + +Open **http://localhost:8081/register** on the laptop. +Keep the laptop port at 8081 to match the configured browser origin. +The defaults are `--api-rp-id localhost` and `--api-origin http://localhost:8081`. + +For direct VPN access, the default loopback listener is insufficient. +You need a reachable listener, HTTPS termination, and matching RP ID and origin options. +Plain HTTP on the VPN hostname does not provide the browser's secure context for passkeys. +The SSH tunnel avoids that extra setup for localhost tests. +**To check with Surma:** the laptop tunnel, browser passkey registration, secret entry, and request approval. +The planned production origin is `https://sausage.apps.surma.technology`, with RP ID `sausage.apps.surma.technology`. +Passkeys registered for `localhost` do not transfer to that RP ID. Plan a separate production registration. +Do not expose the listener before the first registration to people who must not approve requests. + +### Register, create an item, enter a secret + +1. Open `/register` and choose a label for the first passkey. +2. Complete the browser's passkey ceremony. +3. Ask the agent to file an item creation request through `POST /cred/requests`. +4. Review the request on `/approve/REQUEST_ID` and approve it with your passkey. +5. Read the item's public metadata to find its item ID and stable field ID. +6. Open `/entry/ITEM_ID/FIELD_ID` on the host origin. +7. Enter the secret in the blank host form and complete its passkey ceremony. + +Until the first passkey exists, D53 permits unlocked decisions and first registration. +After registration, decisions require a passkey. Additional registrations require an existing passkey. +Secret entry requires a passkey even during bootstrap. +The browser encrypts the secret before submission. The world never receives its plaintext. +Do not paste secrets into chat or world files. + +There is no item editor or credential dashboard yet. +Item creation uses `kind: "edit"` and a stable request key. +Its request has `item: null`, `base_version: null`, and a `change` array. +The creation operation is `{op: "create_item", config: ITEM}`. +`ITEM` must contain the complete public item configuration, including its fields, rules, and transformation. +Further edits add fields and rules. Inspect the typed operations before you construct those edits. +Public reads use `/cred/items` and `/cred/items/ITEM_ID`. +They do not return stored secret values. + +### Approve a request + +The agent or world UI obtains pending requests from `/harness/credential-requests` or `/cred/requests?state=pending`. +Follow the approval link on the host origin through the tunnel. +Review the stored summary and public details, choose limits when applicable, then approve or deny. +The world can file a request. It cannot approve its own request. + +Sources: [`host API`](../host/src/api/README.md), [`pages.rs`](../host/src/api/pages.rs), +[`edits.rs`](../host/src/cred/edits.rs), and [`app.js`](../host/src/api/app.js). + +## Models + +The host requires an `llm-proxy` credential before the harness starts. +On an unlocked new store, it can bootstrap that item from these host environment variables: + +- `PI_PROXY_BASE_URL`: the proxy base URL, without the appended `/v1`. +- `PI_PROXY_API_KEY`: the proxy API key. + +Supply them through your host secret mechanism. Do not print them or commit them. +Do not put either value in a world configuration file. +Once the item exists, the host uses it rather than replacing it from the environment. +The bootstrap is not a general credential import interface for a locked store. + +World model families live at `/src/sausage/agents/models.json`. +Agent definitions live in `/src/sausage/agents/*.md`. +Project overlays live in `/.sausage/agents/`. +The models extension reads these files at each run boundary. Edits affect the next run without a restart. +An explicit model override can take precedence over a family. +The initial console root has a host-defined model. Do not assume a family edit changes every existing conversation. +All real model requests retain the host proxy transport. + +Sources: [`credentials.rs`](../host/src/harness/credentials.rs), [`main.ts`](../host/js/main.ts), +and [`models`](../extensions/models/README.md). + +## Change extensions + +The world checkout is `/src/sausage`. The loader follows its **`main`** branch, not the host checkout's `v3` branch. +Each extension lives at `extensions/NAME/server.ts` inside that checkout. +Commit extension edits to `main` with world git. A saved, uncommitted edit does not reload an extension. +Use `--no-gpg-sign` for commits. + +The loader imports a detached snapshot under `/run/sausage/extensions/COMMIT`. +It validates the entire candidate before it replaces the live registry. +A broken candidate leaves the previous good commit active. +An active tool call retains its implementation. The next call uses the new implementation. + +Read `/proc/extensions` through the agent's file tools to inspect `current`, `good`, and `error`. +To restore the last good branch ref, ask the agent to execute: + +```sh +extensions-revert +``` + +The command preserves editable files. It changes `refs/heads/main`, which triggers the normal reload. +At startup, the loader tries the tip and then the last good commit before it resumes work. + +Source: [`extensions`](../extensions/README.md) and [`extensions.ts`](../host/js/extensions.ts). + +## Everyday tools inside the world + +These are world programs, not console commands. Ask the agent to run them. + +| Tool | Use and limits | +| --- | --- | +| `cargo` | Build, run, and test Rust programs. The host copies the workspace through the VFS, fetches dependencies, then builds offline in namespaces. It publishes WASI artifacts into the world. Other targets are unsupported. | +| `box` | Build non-Rust projects or run browser tests in disposable containers. The default service is `citadel.vpn.surma.technology:8093`. It requires network access and service availability. Remove boxes after use. | +| `undo` | Inspect tags and restore filesystem changes. Later edits can conflict. Network effects and native builds are not reversible. Retention is seven days or 4 GiB of unique content. | +| `systemctl` | Control world init services, not Citadel's systemd. Persistent units live under `/etc/systemd/system`. Init observes edits without daemon reload. | +| `journalctl` | Read world service logs under `/var/log/journal`. Each unit retains a 1 MiB log and one rotated file. | +| `git` | Manage world repositories. HTTPS credential rules can inject authorization through the host HTTP hook. SSH remotes use host signing. | +| `ssh` | Execute a remote command without a PTY. Public identities and signatures come from the host. Private keys never enter the world. Host keys live in the world's known_hosts file. | + +Init starts when the host starts the runtime, if `/bin/init.wasm` exists. +Transient units under `/run/systemd/transient` disappear after restart. +A missing credential grant causes a pending host request, not an automatic approval. +A changed SSH host key fails even with the accept-new policy. + +Command reference and implementation evidence: +[`builder`](../host/src/builder/README.md), [`box`](../userland/box/README.md), +[`undo`](../userland/undo/README.md), [`init`](../userland/init/README.md), +[`git`](../userland/git/README.md), and [`ssh`](../userland/ssh/README.md). + +## Build a world UI with the host API + +World programs call **`http://host.sausage`** through standard WASI HTTP. +The listener on port 8081 does **not** expose harness routes. +A browser UI needs a world server that forwards these requests. +Keep credential approval and secret entry on host pages. + +| Method and route | Result or request | +| --- | --- | +| `GET /harness/conversations` | Conversation records, titles, ownership, ancestry, and state. | +| `GET /harness/conversations/ID/entries?after=0` | `{entries:[...]}` in ascending order. | +| `GET /harness/conversations/ID/stream?after=0` | NDJSON entries, replay first, then live commits. | +| `POST /harness/conversations/ID/submit` | `{content:"text",requestId:"stable-id"}` returns 202 and a submission ID. Reuse the request ID for retries. | +| `POST /harness/conversations/ID/stop` | Stops active turns and owned work. Queued input remains. | +| `GET /harness/events?after=0` | NDJSON events, replay first, then live commits. | +| `POST /harness/extensions/NAME/actions/ACTION` | `{args:{...},conversationId?:number}` invokes an exported user action. | +| `GET /harness/models` | Public model catalog without authentication data. | +| `GET /harness/families` | World model families. | +| `GET /harness/extensions` | Loaded extension status. | +| `GET /harness/credential-requests` | Pending conversation requests with host approval links. | + +The `after` cursor is exclusive. Reconnect with the last record ID. +Streams close when the client disconnects or the harness stops. +Do not render a stream as one JSON response. +An unavailable harness returns 503. Invalid input returns 400. Missing resources return 404. + +Source: [`host API contract`](../host/src/api/README.md), +[`harness-api.ts`](../host/js/harness-api.ts), and [`harness/http.rs`](../host/src/harness/http.rs). + +## Known gaps before cutover + +- The Nix package wraps the host and builder tools but does not ship or select the default userland bundle. +- The development bundle passes fresh-world startup checks. The Nix package start remains unverified until its bundle integration lands. +- Laptop access, passkey ceremonies, secret entry, and browser approval remain **to check with Surma**. +- Startup checks use fake proxy credentials. They do not verify a real model session or external credential injection. +- A usable `llm-proxy` credential is mandatory even before the first chat. +- The browser has approval, passkey, and secret-entry pages, but no item editor, dashboard, or chat UI. +- The console has no shell commands, transcript navigation, or special quit command. +- `sausage sh` requires `-c`. Its interactive shell is not implemented. +- The flake's runtime-toolchain check and loop comments still describe v2 behavior. +- This guide does not migrate a v2 world. Keep the live v2 folder untouched. + +A successful development start does not verify the Nix package, external services, or the production passkey origin. diff --git a/extensions/workspaces/lander.ts b/extensions/workspaces/lander.ts index e26e5c0..d4a650e 100644 --- a/extensions/workspaces/lander.ts +++ b/extensions/workspaces/lander.ts @@ -34,7 +34,7 @@ export function lander(host:any,workspaces:any) { const remote=host.settings().lander?.push if(remote)await run(path,'fetch',remote,entry.target) if(remote)await run(path,'merge','--no-edit','FETCH_HEAD') - await run(path,'merge','--no-ff','--no-edit','-m',`Merge from #${entry.conversationId} into ${entry.target}`,entry.tip) + await run(path,'merge','--no-ff','--no-edit','-m',`Merge from #${entry.conversationId} into ${entry.target}\n\nSausage-Conversation: ${entry.conversationId}`,entry.tip) const sha=await run(path,'rev-parse','HEAD') for(const configured of host.settings().lander?.checks??[]) { const check=typeof configured==='string'?{command:configured}:configured diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 3181925..f3ebf15 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -349,6 +349,15 @@ impl Runtime { .env .push(("PATH".into(), "/usr/local/bin:/bin".into())); } + // Keep process attribution aligned with the nearest conversation, including children. + if let Some(conversation) = conversations.first() { + request + .env + .retain(|(name, _)| name != "SAUSAGE_CONVERSATION"); + request + .env + .push(("SAUSAGE_CONVERSATION".into(), conversation.clone())); + } request.cwd = sausage_vfs::resolve("/", &request.cwd); request.env.retain(|(name, _)| name != "PWD"); request.env.push(("PWD".into(), request.cwd.clone())); diff --git a/host/src/world.rs b/host/src/world.rs index f740b10..70cf941 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -42,6 +42,7 @@ impl World { } /// Copy an optional bundle into a new world. Existing worlds retain their programs. + /// Ensure `/tmp`, `/run`, and `/var/tmp` exist on every startup. /// Each `.wasm` tool keeps its independent file and gains a relative `/bin/` symlink. /// `/dev` exposes generated null, zero, and random byte devices in every world. /// Missing `/etc/gitconfig` defaults to Sausage . Local repository config overrides it. @@ -50,6 +51,9 @@ impl World { let host_dir = dir.join("host"); let fresh = !root_dir.exists(); std::fs::create_dir_all(root_dir.join("usr/local/bin"))?; + for path in ["tmp", "run", "var/tmp"] { + std::fs::create_dir_all(root_dir.join(path))?; + } if fresh && let Some(bundle) = userland { for folder in ["bin", "src"] { copy_tree(&bundle.join(folder), &root_dir.join(folder))?; diff --git a/host/tests/it/git_runtime.rs b/host/tests/it/git_runtime.rs index 17fae59..aea49a8 100644 --- a/host/tests/it/git_runtime.rs +++ b/host/tests/it/git_runtime.rs @@ -125,6 +125,37 @@ async fn run(fixture: &Fixture, script: &str) -> String { assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output)); String::from_utf8(output).unwrap() } +#[tokio::test(flavor = "current_thread")] +async fn conversation_bash_commit_inherits_nearest_attribution() { + let fixture = fixture(); + let mut options = options(); + options.conversations = vec!["158".into(), "79".into()]; + options + .env + .push(("SAUSAGE_CONVERSATION".into(), "wrong".into())); + let mut output = Vec::new(); + let code = fixture + .runtime + .exec( + Command::Shell("sh -c 'git commit --allow-empty --no-gpg-sign -m attributed'".into()), + options, + &mut |_, bytes| output.extend_from_slice(bytes), + ) + .await + .unwrap(); + assert_eq!(code, 0, "{}", String::from_utf8_lossy(&output)); + let commit = std::process::Command::new("git") + .args(["log", "worker", "-1", "--format=%B"]) + .current_dir(fixture._temp.path().join("world/src/demo")) + .output() + .unwrap(); + assert!(commit.status.success()); + let text = String::from_utf8(commit.stdout).unwrap(); + assert!(text.contains("Sausage-Conversation: 158"), "{text}"); + assert!(!text.contains("Sausage-Conversation: 79"), "{text}"); + assert!(!text.contains("Sausage-Conversation: wrong"), "{text}"); +} + async fn edit(fixture: &Fixture) { let file = fixture .root diff --git a/host/tests/it/p1.rs b/host/tests/it/p1.rs index 2b2a070..3855d09 100644 --- a/host/tests/it/p1.rs +++ b/host/tests/it/p1.rs @@ -1,14 +1,15 @@ -//! P1 integration: independent bundle copies and real shell calls from the durable harness. +//! P1 integration: bundle contents, source commits, extension loading, and real shell calls. use sausage::{harness::run_module, world::World}; use sausage_vfs::Open; use std::path::PathBuf; -#[tokio::test(flavor = "current_thread")] -async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { +fn bundle() -> anyhow::Result { let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); - let bundle = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()).join("bundle"); - if !bundle.join("bin/sh.wasm").exists() { - let _guard = crate::common::BUILD.lock().unwrap(); + let bundle = crate::common::target_dir().join("bundle"); + let _guard = crate::common::BUILD.lock().unwrap(); + if !bundle.join("bin/sh.wasm").exists() + || !bundle.join("src/sausage/agents/models.json").exists() + { assert!( std::process::Command::new("nu") .arg(repo.join("userland/bundle.nu")) @@ -17,9 +18,47 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { .success() ); } + Ok(bundle) +} + +#[tokio::test(flavor = "current_thread")] +async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); + let bundle = bundle()?; let dir = tempfile::tempdir()?; let world = World::open_with_userland(dir.path(), Some(&bundle))?; assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0); + let snapshot = dir.path().join("world/src/sausage"); + for path in [ + "agents/models.json", + "agents/ultimate.md", + "skills/README.md", + "extensions/README.md", + ] { + assert!(snapshot.join(path).is_file(), "missing bundled {path}"); + } + let git = |path: &std::path::Path, args: &[&str]| -> anyhow::Result { + let output = std::process::Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output()?; + anyhow::ensure!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) + }; + let message = git(&snapshot, &["log", "-1", "--format=%s"])?; + let revision = message.strip_prefix("Bundle source ").unwrap(); + for path in ["agents", "skills", "extensions"] { + assert_eq!( + git(&snapshot, &["rev-parse", &format!("HEAD:{path}")])?, + git(&repo, &["rev-parse", &format!("{revision}:{path}")])?, + "bundled {path} must match the source commit" + ); + } use std::os::unix::fs::MetadataExt; for entry in std::fs::read_dir(dir.path().join("world/bin"))? { let entry = entry?; @@ -243,3 +282,47 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { assert!(world.root.stat("/bin/sh", true).await.is_err()); Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn bundled_extensions_load() -> anyhow::Result<()> { + let bundle = bundle()?; + let dir = tempfile::tempdir()?; + let world = World::open_with_userland(dir.path(), Some(&bundle))?; + let root = world.root.clone(); + let exec = sausage::process::exec(World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + })?; + // Use the compiled production loader without the console entrypoint or model proxy. + let (library, _) = include_str!("../../js/bundle.js") + .split_once("// main.ts") + .expect("console bundle entrypoint"); + let source = format!( + r#"{library} + const bundleModels = createModels(); + const bundleRegistry = createRegistry(); + bundleRegistry.install(CodingTools2); + bundleRegistry.install(CredentialRequests); + const bundleStorage = await openStorage(globalThis.storagePath); + const bundleHarness = await Harness.open(bundleStorage, {{ + models: bundleModels, registry: bundleRegistry, + settings: {{ get extensions() {{ return bundleRegistry.snapshot().installed(); }} }} + }}, BACKGROUND_CONTEXT); + await bundleHarness.root(BACKGROUND_CONTEXT); + const bundleLoader = await loadExtensions(bundleHarness, bundleRegistry, bundleStorage, + bundleModels, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]); + if (bundleLoader.state().error) throw new Error(bundleLoader.state().error); + await bundleLoader.close(); + await bundleHarness.close(BACKGROUND_CONTEXT); + "# + ); + run_module(world, exec, &source).await?; + let status = root.open("/proc/extensions", Open::default()).await?; + let status: serde_json::Value = serde_json::from_slice(&status.read(0, 65536).await?)?; + let commit = + std::fs::read_to_string(dir.path().join("world/src/sausage/.git/refs/heads/main"))?; + assert_eq!(status["good"].as_str(), Some(commit.trim()), "{status}"); + assert!(status["error"].is_null(), "{status}"); + Ok(()) +} diff --git a/host/tests/it/process.rs b/host/tests/it/process.rs index 23dac35..1c35792 100644 --- a/host/tests/it/process.rs +++ b/host/tests/it/process.rs @@ -57,6 +57,24 @@ fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> { Ok(()) } +#[test] +fn world_restores_temporary_directories_on_startup() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + for restart in [false, true] { + let world = World::open(temp.path())?; + for path in ["tmp", "run", "var/tmp"] { + let directory = temp.path().join("world").join(path); + assert!( + directory.is_dir(), + "missing /{path} after restart={restart}" + ); + std::fs::remove_dir(&directory)?; + } + drop(world); + } + Ok(()) +} + mod features { //! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases. use super::*; diff --git a/host/tests/it/workspaces.rs b/host/tests/it/workspaces.rs index 4f7a801..bdf9a39 100644 --- a/host/tests/it/workspaces.rs +++ b/host/tests/it/workspaces.rs @@ -207,7 +207,29 @@ async fn coordination() -> anyhow::Result<()> { World::open(dir.path())?; prepare(dir.path())?; let _server = serve(dir.path())?; - run(dir.path(), "normal").await + run(dir.path(), "normal").await?; + let output = Command::new("git") + .args(["log", "-1", "--format=%s%n%B"]) + .current_dir(dir.path().join("world/project")) + .output()?; + assert!(output.status.success()); + let message = String::from_utf8(output.stdout)?; + let submitter = message + .lines() + .next() + .unwrap() + .strip_prefix("Merge from #") + .unwrap() + .split_whitespace() + .next() + .unwrap(); + assert!( + message + .lines() + .any(|line| line == format!("Sausage-Conversation: {submitter}")), + "{message}" + ); + Ok(()) } #[tokio::test(flavor = "current_thread")] async fn restart_child() -> anyhow::Result<()> { diff --git a/userland/bundle.nu b/userland/bundle.nu index 7ca2c0c..d04a56c 100644 --- a/userland/bundle.nu +++ b/userland/bundle.nu @@ -41,14 +41,14 @@ def main [destination?: path] { if ($snapshot | path exists) { rm --recursive --force $snapshot } mkdir $snapshot let archive = ($destination | path join source.tar) - ^git -C $repo archive --output $archive HEAD host vfs userland extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + ^git -C $repo archive --output $archive HEAD host vfs userland agents skills extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source archive failed'} } ^tar -xf $archive -C $snapshot if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source extraction failed'} } rm $archive ^git -C $snapshot init --quiet --initial-branch=main if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git init failed'} } - ^git -C $snapshot add host vfs userland extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + ^git -C $snapshot add host vfs userland agents skills extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git add failed'} } with-env { GIT_AUTHOR_NAME: 'Sausage', GIT_AUTHOR_EMAIL: 'sausage@localhost',